mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
feat(context-packs): operator-authored composable injection v0 (PL-014)
PRD: slices/rig-context-composable-injection-v0/IMPLEMENTATION-PRD.md.
New typed primitive parallel to skills + workflow_specs.
Filesystem-canonical at ~/.openrig/context-packs/<name>/ +
workspace-local .openrig/context-packs/<name>/. NO new SQLite tables,
NO new migrations, NO new event types.
Daemon (Items 1, 2, 5)
- New domain/context-packs/{context-pack-types, manifest-parser,
context-pack-library-service, bundle-assembler}.ts.
- Manifest schema: name + version + purpose + files[{path, role,
summary?}] + estimated_tokens? — guards against ../-escapes,
absolute paths, unsupported suffixes (.md/.markdown/.yaml/.yml/.txt).
- Library service walks discovery roots in workspace > user_file >
builtin order (last-wins on collision); workspace-surface
reconciliation via re-scan (operator filesystem edit always wins).
- Bundle assembler concatenates files with `## File: <path> (role:
<role>)` framing into ONE coherent paste-ready string. Missing
files surfaced as warning instead of hard fail. Token estimate via
the existing chars/4 heuristic.
- New /api/context-packs/{library, library/sync, library/:id,
library/:id/preview, library/:id/send} routes. send invokes
SessionTransport.send (one paste, NOT N sends — matches PRD § Item
5 "single coherent priming injection"); supports dryRun for the
UI's preview-before-send flow.
CLI (Item 7)
- New `rig context-pack` family: list / show / preview / sync / add /
send. add installs from a local directory under
~/.openrig/context-packs/, refuses symlinks (parallel to `rig specs
add` contract), and triggers a daemon sync after install.
UI (Items 3, 4)
- SpecsPanel adds 6th filter chip "context-packs" + PACK badge +
per-row summary (files-count + token estimate).
- LibraryReview dispatches to a new ContextPack review variant for
context-pack:* ids: header + 4-card summary grid + per-file list
with role/size/token estimate (MISSING marker for absent files) +
source-path display + "Send to seat" modal with running-session
picker + Dry-run / Send buttons + bundle preview pre-block.
- New useContextPackLibrary / useContextPackPreview /
useContextPackSend / useContextPackSync hooks.
Item 6 carve-out (NAMED v0+1 trigger)
- AgentSpec startup_files extension (kind: context_pack as a startup-
file source) — DEFERRED. PRD § Bounce Conditions allows this with
named v0+1 trigger; founder dispatch was hands-off and didn't carve
it out explicitly. Operator can still send packs to a fresh seat
via `rig context-pack send` from a startup script or interactive
command — the headline operator value (typed primitive + library +
review + send) ships fully without it.
v0+1 NAMED trigger: operator dogfood reports declaring kind:
context_pack in agent.yaml startup_files would be friction over
invoking rig context-pack send manually after launch.
PRD § from_addresses extension is gated by PL-022 markdown-addressing
(PARKED) — out of v0 per dispatch.
Cross-CLI-version drift handling
- UI useContextPackLibrary returns [] on 503 + Array.isArray guard
on the body so older daemons without the route surface as "no
packs in library" rather than crashing the SpecsPanel.
Tests
- daemon: 2956/2956 (12 manifest-parser + 11 library-service + 5
bundle-assembler + 11 routes = 39 new)
- cli: 842/842 (9 new context-pack subcommand tests)
- ui: 543/543 (2 new SpecsPanel filter tests + 2 new LibraryReview
context-pack variant tests)
- tsc clean across all three packages
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
2a70fa03ea
commit
fce486c16d
19 files changed
+2615
-15
No files matched your search
@@ -0,0 +1,356 @@
|
||||
// Rig Context / Composable Context Injection v0 (PL-014) — `rig
|
||||
// context-pack` CLI verb family.
|
||||
//
|
||||
// Six subcommands parallel to `rig specs`:
|
||||
// list / show / preview / add / sync / send
|
||||
//
|
||||
// Each delegates to /api/context-packs/library/* against the daemon.
|
||||
// The `add` verb installs a pack from a directory at
|
||||
// ~/.openrig/context-packs/<name>/ — host-symlink-free contract,
|
||||
// matches `rig specs add` shape (regular files only; no symlinks).
|
||||
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { getDefaultOpenRigPath } from "../openrig-compat.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface ContextPackEntryWire {
|
||||
id: string;
|
||||
kind: "context-pack";
|
||||
name: string;
|
||||
version: string;
|
||||
purpose: string | null;
|
||||
sourceType: "builtin" | "user_file" | "workspace";
|
||||
sourcePath: string;
|
||||
relativePath: string;
|
||||
updatedAt: string;
|
||||
manifestEstimatedTokens: number | null;
|
||||
derivedEstimatedTokens: number;
|
||||
files: Array<{
|
||||
path: string;
|
||||
role: string;
|
||||
summary: string | null;
|
||||
absolutePath: string | null;
|
||||
bytes: number | null;
|
||||
estimatedTokens: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PreviewWire {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
bundleText: string;
|
||||
bundleBytes: number;
|
||||
estimatedTokens: number;
|
||||
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
|
||||
missingFiles: Array<{ path: string; role: string }>;
|
||||
}
|
||||
|
||||
interface SendWire {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
destinationSession: string;
|
||||
bundleBytes: number;
|
||||
estimatedTokens: number;
|
||||
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
|
||||
missingFiles: Array<{ path: string; role: string }>;
|
||||
dryRun: boolean;
|
||||
bundleText?: string;
|
||||
sent?: boolean;
|
||||
reason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function assertTreeHasNoSymlinks(root: string): void {
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!;
|
||||
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||
const absPath = join(current, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`Context pack directories must not contain symlinks: ${absPath}`);
|
||||
}
|
||||
if (entry.isDirectory()) stack.push(absPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePack(client: DaemonClient, nameOrId: string): Promise<ContextPackEntryWire> {
|
||||
// First try as id (context-pack:<name>:<version>); else search by name.
|
||||
if (nameOrId.startsWith("context-pack:")) {
|
||||
const res = await client.get<ContextPackEntryWire>(`/api/context-packs/library/${encodeURIComponent(nameOrId)}`);
|
||||
if (res.status === 200) return res.data;
|
||||
if (res.status === 404) throw new Error(`Context pack '${nameOrId}' not found in library. Run 'rig context-pack list' to see what's available.`);
|
||||
throw new Error(`Daemon returned HTTP ${res.status} for /api/context-packs/library/${nameOrId}`);
|
||||
}
|
||||
const res = await client.get<ContextPackEntryWire[]>("/api/context-packs/library");
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status} for /api/context-packs/library`);
|
||||
const entries = res.data ?? [];
|
||||
const matches = entries.filter((e) => e.name === nameOrId);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`Context pack '${nameOrId}' not found in library. Run 'rig context-pack list' to see what's available.`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
const versions = matches.map((m) => m.version).join(", ");
|
||||
throw new Error(`Context pack '${nameOrId}' is ambiguous (versions: ${versions}). Use the full id 'context-pack:${nameOrId}:<version>'.`);
|
||||
}
|
||||
return matches[0]!;
|
||||
}
|
||||
|
||||
export function contextPackCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("context-pack")
|
||||
.description("Browse, preview, send, and install operator-authored context packs")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig context-pack list
|
||||
rig context-pack show pl-005-phase-a-priming
|
||||
rig context-pack preview pl-005-phase-a-priming
|
||||
rig context-pack add ./my-pack
|
||||
rig context-pack sync
|
||||
rig context-pack send pl-005-phase-a-priming velocity-driver@openrig-velocity --dry-run
|
||||
rig context-pack send pl-005-phase-a-priming velocity-driver@openrig-velocity
|
||||
`);
|
||||
|
||||
const getDeps = (): StatusDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
async function getClient(): Promise<DaemonClient> {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
throw new Error("Daemon not running. Start it with: rig daemon start");
|
||||
}
|
||||
return deps.clientFactory(getDaemonUrl(status));
|
||||
}
|
||||
|
||||
cmd.command("list")
|
||||
.description("List all context packs in the library")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const res = await client.get<ContextPackEntryWire[]>("/api/context-packs/library");
|
||||
const entries = res.data ?? [];
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
return;
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
console.log("No context packs in library. Author one at ~/.openrig/context-packs/<name>/ then run: rig context-pack sync");
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
console.log(`${e.name.padEnd(28)} v${String(e.version).padEnd(6)} ${String(e.files.length).padStart(2)} files ~${String(e.derivedEstimatedTokens).padStart(6)} tokens ${e.sourceType} ${e.sourcePath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("show")
|
||||
.argument("<name-or-id>", "Context pack name or library ID")
|
||||
.description("Show pack manifest + per-file metadata")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (nameOrId: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const entry = await resolvePack(client, nameOrId);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(entry, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`Name: ${entry.name}`);
|
||||
console.log(`Version: ${entry.version}`);
|
||||
console.log(`Source: ${entry.sourceType} (${entry.sourcePath})`);
|
||||
console.log(`Files: ${entry.files.length}`);
|
||||
console.log(`Tokens (~): ${entry.derivedEstimatedTokens}${entry.manifestEstimatedTokens !== null ? ` (manifest: ${entry.manifestEstimatedTokens})` : ""}`);
|
||||
if (entry.purpose) {
|
||||
console.log("");
|
||||
console.log("Purpose:");
|
||||
console.log(` ${entry.purpose.replaceAll("\n", "\n ")}`);
|
||||
}
|
||||
console.log("");
|
||||
for (const f of entry.files) {
|
||||
const sizeStr = f.bytes === null ? "(missing)" : `${f.bytes}B`;
|
||||
const tokenStr = f.estimatedTokens === null ? "—" : `~${f.estimatedTokens} tokens`;
|
||||
console.log(` ${f.path.padEnd(40)} role=${f.role.padEnd(20)} ${sizeStr.padEnd(12)} ${tokenStr}`);
|
||||
if (f.summary) console.log(` ${f.summary}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("preview")
|
||||
.argument("<name-or-id>", "Context pack name or library ID")
|
||||
.description("Show the assembled bundle (the exact text that would be sent)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (nameOrId: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const entry = await resolvePack(client, nameOrId);
|
||||
const res = await client.get<PreviewWire>(`/api/context-packs/library/${encodeURIComponent(entry.id)}/preview`);
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
const preview = res.data;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(preview, null, 2));
|
||||
return;
|
||||
}
|
||||
if (preview.missingFiles.length > 0) {
|
||||
console.error(`Warning: ${preview.missingFiles.length} file(s) referenced by manifest are missing on disk:`);
|
||||
for (const m of preview.missingFiles) console.error(` - ${m.path} (role: ${m.role})`);
|
||||
console.error("");
|
||||
}
|
||||
console.log(`# Preview: ${preview.name} v${preview.version}`);
|
||||
console.log(`# Bundle: ${preview.bundleBytes} bytes (~${preview.estimatedTokens} tokens), ${preview.files.length} files`);
|
||||
console.log("# ---");
|
||||
console.log(preview.bundleText);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("sync")
|
||||
.description("Re-walk discovery roots and refresh the library index")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const res = await client.post<{ count: number; errors: Array<{ source: string; error: string }>; entries: ContextPackEntryWire[] }>(
|
||||
"/api/context-packs/library/sync",
|
||||
);
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
const data = res.data;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`Indexed ${data.count} context pack(s).`);
|
||||
if (data.errors.length > 0) {
|
||||
console.log(`Encountered ${data.errors.length} parse error(s):`);
|
||||
for (const e of data.errors) console.log(` - ${e.source}: ${e.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("add")
|
||||
.argument("<source-dir>", "Directory containing manifest.yaml + included files")
|
||||
.description("Install a context pack from a local directory into ~/.openrig/context-packs/")
|
||||
.option("--name <name>", "Override the install name (defaults to source directory basename)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (sourceDir: string, opts: { name?: string; json?: boolean }) => {
|
||||
try {
|
||||
if (!existsSync(sourceDir)) throw new Error(`Source directory not found: ${sourceDir}`);
|
||||
const stat = lstatSync(sourceDir);
|
||||
if (stat.isSymbolicLink()) throw new Error(`Source must not be a symlink: ${sourceDir}`);
|
||||
if (!stat.isDirectory()) throw new Error(`Source must be a directory containing manifest.yaml: ${sourceDir}`);
|
||||
const manifestPath = join(sourceDir, "manifest.yaml");
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw new Error(`Source directory must contain manifest.yaml: ${sourceDir}`);
|
||||
}
|
||||
// Read the manifest's name as the canonical install name when
|
||||
// --name not given. Cheap parse: trust the daemon to validate
|
||||
// on next sync; here we just need the name.
|
||||
const installName = opts.name ?? (() => {
|
||||
try {
|
||||
const raw = readFileSync(manifestPath, "utf-8");
|
||||
const m = raw.match(/^name:\s*['"]?([^'"\n]+)['"]?\s*$/m);
|
||||
return m?.[1]?.trim() || basename(sourceDir);
|
||||
} catch {
|
||||
return basename(sourceDir);
|
||||
}
|
||||
})();
|
||||
assertTreeHasNoSymlinks(sourceDir);
|
||||
const targetRoot = getDefaultOpenRigPath("context-packs");
|
||||
mkdirSync(targetRoot, { recursive: true });
|
||||
const targetDir = join(targetRoot, installName);
|
||||
if (existsSync(targetDir)) {
|
||||
throw new Error(`A context pack named '${installName}' already exists at ${targetDir}. Remove it first or use --name to install under a different name.`);
|
||||
}
|
||||
cpSync(sourceDir, targetDir, { recursive: true });
|
||||
// Sync the daemon library so the new pack appears immediately.
|
||||
const client = await getClient();
|
||||
const syncRes = await client.post<{ count: number; entries: ContextPackEntryWire[] }>("/api/context-packs/library/sync");
|
||||
if (syncRes.status !== 200) {
|
||||
// Install succeeded; sync failed → still surface install path.
|
||||
if (opts.json) console.log(JSON.stringify({ installedAt: targetDir, syncError: `HTTP ${syncRes.status}` }, null, 2));
|
||||
else console.log(`Installed at ${targetDir}; daemon sync failed (HTTP ${syncRes.status}). Run 'rig context-pack sync' manually.`);
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ installedAt: targetDir, count: syncRes.data.count }, null, 2));
|
||||
} else {
|
||||
console.log(`Installed at ${targetDir}. Library now has ${syncRes.data.count} context pack(s).`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("send")
|
||||
.argument("<name-or-id>", "Context pack name or library ID")
|
||||
.argument("<destination-session>", "Destination session name (e.g., velocity-driver@openrig-velocity)")
|
||||
.description("Assemble the pack into one paste-ready bundle and send to a seat")
|
||||
.option("--dry-run", "Show what would be sent without invoking SessionTransport")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (nameOrId: string, destinationSession: string, opts: { dryRun?: boolean; json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const entry = await resolvePack(client, nameOrId);
|
||||
const res = await client.post<SendWire>(`/api/context-packs/library/${encodeURIComponent(entry.id)}/send`, {
|
||||
destinationSession,
|
||||
dryRun: opts.dryRun ?? false,
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
// 502/503/etc. — surface daemon error verbatim.
|
||||
const data = res.data as Partial<SendWire> & { error?: string; reason?: string };
|
||||
throw new Error(data.error ?? data.reason ?? `Daemon returned HTTP ${res.status}`);
|
||||
}
|
||||
const data = res.data;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
return;
|
||||
}
|
||||
if (data.dryRun) {
|
||||
if (data.missingFiles.length > 0) {
|
||||
console.error(`Warning: ${data.missingFiles.length} manifest file(s) missing on disk.`);
|
||||
}
|
||||
console.log(`(dry-run) ${data.name} v${data.version} → ${data.destinationSession}`);
|
||||
console.log(`Bundle: ${data.bundleBytes} bytes (~${data.estimatedTokens} tokens), ${data.files.length} files`);
|
||||
if (data.bundleText) {
|
||||
console.log("# ---");
|
||||
console.log(data.bundleText);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log(`Sent ${data.name} v${data.version} (${data.bundleBytes} bytes) to ${data.destinationSession}.`);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { envCommand } from "./commands/env.js";
|
||||
import { askCommand } from "./commands/ask.js";
|
||||
import { chatroomCommand } from "./commands/chatroom.js";
|
||||
import { specsCommand } from "./commands/specs.js";
|
||||
import { contextPackCommand } from "./commands/context-pack.js";
|
||||
import { whoamiCommand } from "./commands/whoami.js";
|
||||
import { unclaimCommand } from "./commands/unclaim.js";
|
||||
import { releaseCommand } from "./commands/release.js";
|
||||
@@ -93,6 +94,7 @@ export interface ProgramDeps {
|
||||
askDeps?: StatusDeps;
|
||||
chatroomDeps?: StatusDeps;
|
||||
specsDeps?: StatusDeps;
|
||||
contextPackDeps?: StatusDeps;
|
||||
whoamiDeps?: StatusDeps;
|
||||
expandDeps?: StatusDeps;
|
||||
envDeps?: StatusDeps;
|
||||
@@ -151,6 +153,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(askCommand(depsOverride?.askDeps));
|
||||
program.addCommand(chatroomCommand(depsOverride?.chatroomDeps));
|
||||
program.addCommand(specsCommand(depsOverride?.specsDeps));
|
||||
program.addCommand(contextPackCommand(depsOverride?.contextPackDeps));
|
||||
program.addCommand(whoamiCommand(depsOverride?.whoamiDeps));
|
||||
program.addCommand(configCommand(depsOverride?.configPath));
|
||||
program.addCommand(preflightCommand());
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// Rig Context / Composable Context Injection v0 (PL-014) — `rig
|
||||
// context-pack` CLI verb family tests.
|
||||
//
|
||||
// Stands up a small in-memory daemon mock for the /api/context-packs/*
|
||||
// surface and exercises each subcommand against it.
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { contextPackCommand } from "../src/commands/context-pack.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
|
||||
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const errLogs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
|
||||
console.error = (...args: unknown[]) => { errLogs.push(args.map(String).join(" ")); };
|
||||
process.exitCode = undefined;
|
||||
try { await fn(); } catch { /* commander.exitOverride */ }
|
||||
const exitCode = process.exitCode;
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, errLogs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
function runningDeps(port: number): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" } as DaemonState);
|
||||
return null;
|
||||
}),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
}),
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
const FIXTURE_PACK = {
|
||||
id: "context-pack:smoke:1",
|
||||
kind: "context-pack" as const,
|
||||
name: "smoke",
|
||||
version: "1",
|
||||
purpose: "Smoke test pack",
|
||||
sourceType: "user_file" as const,
|
||||
sourcePath: "/home/op/.openrig/context-packs/smoke",
|
||||
relativePath: "smoke",
|
||||
updatedAt: "2026-05-04T00:00:00Z",
|
||||
manifestEstimatedTokens: null,
|
||||
derivedEstimatedTokens: 100,
|
||||
files: [
|
||||
{ path: "notes.md", role: "notes", summary: "Smoke notes", absolutePath: "/abs/notes.md", bytes: 50, estimatedTokens: 13 },
|
||||
],
|
||||
};
|
||||
|
||||
const FIXTURE_PREVIEW = {
|
||||
id: FIXTURE_PACK.id,
|
||||
name: FIXTURE_PACK.name,
|
||||
version: FIXTURE_PACK.version,
|
||||
bundleText: "# OpenRig Context Pack: smoke v1\n\nSmoke test pack\n\n## File: notes.md (role: notes) — Smoke notes\n\nSmoke body\n",
|
||||
bundleBytes: 110,
|
||||
estimatedTokens: 28,
|
||||
files: [{ path: "notes.md", role: "notes", bytes: 50, estimatedTokens: 13 }],
|
||||
missingFiles: [] as Array<{ path: string; role: string }>,
|
||||
};
|
||||
|
||||
describe("rig context-pack CLI (PL-014)", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let sendLog: Array<{ id: string; body: unknown }>;
|
||||
let sendBehavior: "ok" | "fail" = "ok";
|
||||
|
||||
beforeAll(async () => {
|
||||
sendLog = [];
|
||||
server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
||||
req.on("end", () => {
|
||||
const url = req.url ?? "";
|
||||
if (url === "/api/context-packs/library" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([FIXTURE_PACK]));
|
||||
return;
|
||||
}
|
||||
if (url === "/api/context-packs/library/sync" && req.method === "POST") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ count: 1, errors: [], entries: [FIXTURE_PACK] }));
|
||||
return;
|
||||
}
|
||||
const idMatch = url.match(/^\/api\/context-packs\/library\/([^/]+)(\/(preview|send))?$/);
|
||||
if (idMatch) {
|
||||
const id = decodeURIComponent(idMatch[1]!);
|
||||
const sub = idMatch[3];
|
||||
if (id !== FIXTURE_PACK.id) {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: `Context pack '${id}' not found in library` }));
|
||||
return;
|
||||
}
|
||||
if (sub === "preview" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(FIXTURE_PREVIEW));
|
||||
return;
|
||||
}
|
||||
if (sub === "send" && req.method === "POST") {
|
||||
const parsed = JSON.parse(body || "{}") as { destinationSession?: string; dryRun?: boolean };
|
||||
sendLog.push({ id, body: parsed });
|
||||
if (sendBehavior === "fail") {
|
||||
res.writeHead(502, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Session not found", reason: "session_missing" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
...FIXTURE_PREVIEW,
|
||||
destinationSession: parsed.destinationSession,
|
||||
dryRun: !!parsed.dryRun,
|
||||
...(parsed.dryRun ? {} : { sent: true }),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!sub && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(FIXTURE_PACK));
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterAll(() => server.close());
|
||||
|
||||
function makeCmd(): Command {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(contextPackCommand(runningDeps(port)));
|
||||
return prog;
|
||||
}
|
||||
|
||||
it("list shows discovered packs", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "list"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(logs.join("\n")).toContain("smoke");
|
||||
expect(logs.join("\n")).toContain("1 files");
|
||||
});
|
||||
|
||||
it("list --json emits JSON", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "list", "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join("")) as Array<{ name: string }>;
|
||||
expect(parsed[0]!.name).toBe("smoke");
|
||||
});
|
||||
|
||||
it("show resolves by name", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "show", "smoke"]);
|
||||
});
|
||||
expect(logs.join("\n")).toContain("Name: smoke");
|
||||
expect(logs.join("\n")).toContain("Smoke test pack");
|
||||
});
|
||||
|
||||
it("show fails with helpful error on unknown name", async () => {
|
||||
const { errLogs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "show", "missing-pack"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errLogs.join("\n")).toContain("not found in library");
|
||||
});
|
||||
|
||||
it("preview prints the assembled bundle text", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "preview", "smoke"]);
|
||||
});
|
||||
expect(logs.join("\n")).toContain("# OpenRig Context Pack: smoke v1");
|
||||
expect(logs.join("\n")).toContain("Smoke body");
|
||||
});
|
||||
|
||||
it("send --dry-run does not invoke the daemon send path twice", async () => {
|
||||
sendLog.length = 0;
|
||||
sendBehavior = "ok";
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "driver@rig", "--dry-run"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(sendLog).toHaveLength(1);
|
||||
expect(sendLog[0]!.body).toEqual({ destinationSession: "driver@rig", dryRun: true });
|
||||
expect(logs.join("\n")).toContain("(dry-run)");
|
||||
});
|
||||
|
||||
it("send (real) reports successful delivery", async () => {
|
||||
sendLog.length = 0;
|
||||
sendBehavior = "ok";
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "driver@rig"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(sendLog[0]!.body).toEqual({ destinationSession: "driver@rig", dryRun: false });
|
||||
expect(logs.join("\n")).toContain("Sent smoke v1");
|
||||
});
|
||||
|
||||
it("send fails fast when daemon returns 502", async () => {
|
||||
sendLog.length = 0;
|
||||
sendBehavior = "fail";
|
||||
const { errLogs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "missing@rig"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errLogs.join("\n")).toContain("Session not found");
|
||||
sendBehavior = "ok";
|
||||
});
|
||||
|
||||
it("sync reports indexed count", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "sync"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(logs.join("\n")).toContain("Indexed 1 context pack");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// Rig Context / Composable Context Injection v0 (PL-014) — bundle
|
||||
// assembler.
|
||||
//
|
||||
// Concatenates a context_pack's included files into a single coherent
|
||||
// paste-ready string. Each file gets a `## File: <path> (role: <role>)`
|
||||
// header so the destination seat can recognize the bundle structure.
|
||||
// The full bundle leads with a one-line manifest summary so the
|
||||
// destination has at-a-glance context.
|
||||
//
|
||||
// One coherent paste, NOT N separate sends — matches PRD § Item 5: "the
|
||||
// seat receives the pack as a single coherent priming injection."
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { ContextPackError, type ContextPackEntry } from "./context-pack-types.js";
|
||||
import { estimateTokensFromBytes } from "./context-pack-library-service.js";
|
||||
|
||||
export interface AssembledBundle {
|
||||
/** Concatenated bundle string ready for SessionTransport.send. */
|
||||
text: string;
|
||||
/** Total length in bytes of the assembled string (UTF-8 encoded). */
|
||||
bytes: number;
|
||||
/** Daemon-derived estimate (chars / 4 rounded). */
|
||||
estimatedTokens: number;
|
||||
/** Per-file metadata threaded through assembly for the dry-run preview. */
|
||||
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
|
||||
/** Files that were referenced in the manifest but missing on disk;
|
||||
* surfaced as a warning in the preview rather than a hard fail so
|
||||
* operator can repair. */
|
||||
missingFiles: Array<{ path: string; role: string }>;
|
||||
}
|
||||
|
||||
export interface AssembleOpts {
|
||||
packEntry: ContextPackEntry;
|
||||
/** Defaults to readFileSync; injected for tests. */
|
||||
readFile?: (absPath: string) => string;
|
||||
}
|
||||
|
||||
const PACK_HEADER_PREFIX = "# OpenRig Context Pack:";
|
||||
const FILE_HEADER_PREFIX = "## File:";
|
||||
|
||||
/**
|
||||
* Assembles a context pack into a single paste-ready string.
|
||||
*
|
||||
* Frame:
|
||||
* # OpenRig Context Pack: <name> v<version>
|
||||
* <purpose, if any>
|
||||
*
|
||||
* ## File: <path> (role: <role>)
|
||||
* <file contents>
|
||||
*
|
||||
* ## File: <path> (role: <role>)
|
||||
* <file contents>
|
||||
*
|
||||
* ...
|
||||
*
|
||||
* Each file is separated by a blank line so adjacent contents don't
|
||||
* accidentally merge into a contiguous markdown block. Missing files
|
||||
* are skipped (operator sees them in `missingFiles` for repair).
|
||||
*/
|
||||
export function assembleBundle(opts: AssembleOpts): AssembledBundle {
|
||||
const { packEntry } = opts;
|
||||
const reader = opts.readFile ?? ((p: string) => readFileSync(p, "utf-8"));
|
||||
|
||||
const sections: string[] = [];
|
||||
sections.push(`${PACK_HEADER_PREFIX} ${packEntry.name} v${packEntry.version}`);
|
||||
if (packEntry.purpose) {
|
||||
sections.push(packEntry.purpose.trim());
|
||||
}
|
||||
|
||||
const files: AssembledBundle["files"] = [];
|
||||
const missingFiles: AssembledBundle["missingFiles"] = [];
|
||||
|
||||
for (const f of packEntry.files) {
|
||||
if (f.absolutePath === null) {
|
||||
missingFiles.push({ path: f.path, role: f.role });
|
||||
continue;
|
||||
}
|
||||
let content: string;
|
||||
try {
|
||||
content = reader(f.absolutePath);
|
||||
} catch (err) {
|
||||
throw new ContextPackError(
|
||||
"file_read_failed",
|
||||
`failed to read pack file ${f.absolutePath}: ${(err as Error).message}`,
|
||||
{ packId: packEntry.id, path: f.path },
|
||||
);
|
||||
}
|
||||
const headerLine = f.summary
|
||||
? `${FILE_HEADER_PREFIX} ${f.path} (role: ${f.role}) — ${f.summary}`
|
||||
: `${FILE_HEADER_PREFIX} ${f.path} (role: ${f.role})`;
|
||||
sections.push(headerLine);
|
||||
sections.push(content.trimEnd());
|
||||
const bytes = Buffer.byteLength(content, "utf-8");
|
||||
files.push({
|
||||
path: f.path,
|
||||
role: f.role,
|
||||
bytes,
|
||||
estimatedTokens: estimateTokensFromBytes(bytes),
|
||||
});
|
||||
}
|
||||
|
||||
const text = sections.join("\n\n") + "\n";
|
||||
const bytes = Buffer.byteLength(text, "utf-8");
|
||||
return {
|
||||
text,
|
||||
bytes,
|
||||
estimatedTokens: estimateTokensFromBytes(bytes),
|
||||
files,
|
||||
missingFiles,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Rig Context / Composable Context Injection v0 (PL-014) — library
|
||||
// service.
|
||||
//
|
||||
// Walks the configured discovery roots, parses each pack's
|
||||
// manifest.yaml, and emits ContextPackEntry records ready for the
|
||||
// daemon HTTP routes + UI library + send mechanism. Workspace-surface
|
||||
// reconciliation: the operator's filesystem edit always wins on next
|
||||
// scan() (matches PL-004 Phase D's contract for workflow_specs).
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { parseManifest } from "./manifest-parser.js";
|
||||
import {
|
||||
ContextPackError,
|
||||
type ContextPackEntry,
|
||||
type ContextPackEntryFile,
|
||||
type ContextPackSourceType,
|
||||
} from "./context-pack-types.js";
|
||||
|
||||
export interface ContextPackLibraryRoot {
|
||||
/** Absolute path to a directory whose immediate children are pack dirs. */
|
||||
path: string;
|
||||
sourceType: ContextPackSourceType;
|
||||
}
|
||||
|
||||
export interface ContextPackLibraryOpts {
|
||||
roots: ContextPackLibraryRoot[];
|
||||
}
|
||||
|
||||
/** Stable id format: context-pack:<name>:<version>. */
|
||||
export function contextPackId(name: string, version: string): string {
|
||||
return `context-pack:${name}:${version}`;
|
||||
}
|
||||
|
||||
export function parseContextPackId(id: string): { name: string; version: string } | null {
|
||||
if (!id.startsWith("context-pack:")) return null;
|
||||
const rest = id.slice("context-pack:".length);
|
||||
const last = rest.lastIndexOf(":");
|
||||
if (last === -1) return null;
|
||||
return { name: rest.slice(0, last), version: rest.slice(last + 1) };
|
||||
}
|
||||
|
||||
/** Daemon-derived per-file token estimate. Same heuristic the existing
|
||||
* context-usage-store uses (≈4 chars/token); cheap, stable, no
|
||||
* dependency on a tokenizer library. */
|
||||
export function estimateTokensFromBytes(bytes: number): number {
|
||||
return Math.ceil(bytes / 4);
|
||||
}
|
||||
|
||||
export class ContextPackLibraryService {
|
||||
private entries = new Map<string, ContextPackEntry>();
|
||||
private readonly roots: ContextPackLibraryRoot[];
|
||||
|
||||
constructor(opts: ContextPackLibraryOpts) {
|
||||
this.roots = opts.roots;
|
||||
}
|
||||
|
||||
/** Re-walk all roots, replace the in-memory index, return a count. */
|
||||
scan(): { count: number; errors: Array<{ source: string; error: string }> } {
|
||||
const next = new Map<string, ContextPackEntry>();
|
||||
const errors: Array<{ source: string; error: string }> = [];
|
||||
|
||||
for (const root of this.roots) {
|
||||
let dirents: import("node:fs").Dirent[];
|
||||
try {
|
||||
dirents = existsSync(root.path)
|
||||
? readdirSync(root.path, { withFileTypes: true })
|
||||
: [];
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const dirent of dirents) {
|
||||
if (!dirent.isDirectory()) continue;
|
||||
const packDir = join(root.path, dirent.name);
|
||||
const manifestPath = join(packDir, "manifest.yaml");
|
||||
if (!existsSync(manifestPath)) continue;
|
||||
try {
|
||||
const entry = this.readPackEntry(packDir, manifestPath, root);
|
||||
// Last-wins: workspace > user_file > builtin in the discovery
|
||||
// order configured by startup.
|
||||
next.set(entry.id, entry);
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
source: packDir,
|
||||
error: err instanceof ContextPackError
|
||||
? `${err.code}: ${err.message}`
|
||||
: (err as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
this.entries = next;
|
||||
return { count: next.size, errors };
|
||||
}
|
||||
|
||||
list(): ContextPackEntry[] {
|
||||
return Array.from(this.entries.values()).sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version));
|
||||
}
|
||||
|
||||
get(id: string): ContextPackEntry | null {
|
||||
return this.entries.get(id) ?? null;
|
||||
}
|
||||
|
||||
getByNameVersion(name: string, version: string): ContextPackEntry | null {
|
||||
return this.entries.get(contextPackId(name, version)) ?? null;
|
||||
}
|
||||
|
||||
/** Resolve the absolute file path for a pack entry's file, with a
|
||||
* containment check that prevents path-traversal escaping the pack
|
||||
* directory. */
|
||||
resolveFileWithinPack(packEntry: ContextPackEntry, relPath: string): string {
|
||||
if (relPath.includes("..") || relPath.startsWith("/")) {
|
||||
throw new ContextPackError(
|
||||
"file_outside_pack",
|
||||
`relative path '${relPath}' must be inside the pack directory (no '..', no leading '/')`,
|
||||
{ packId: packEntry.id, relPath },
|
||||
);
|
||||
}
|
||||
const abs = join(packEntry.sourcePath, relPath);
|
||||
if (!abs.startsWith(packEntry.sourcePath + "/") && abs !== packEntry.sourcePath) {
|
||||
throw new ContextPackError(
|
||||
"file_outside_pack",
|
||||
`resolved path '${abs}' falls outside pack '${packEntry.sourcePath}'`,
|
||||
{ packId: packEntry.id, relPath, resolved: abs },
|
||||
);
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
|
||||
private readPackEntry(
|
||||
packDir: string,
|
||||
manifestPath: string,
|
||||
root: ContextPackLibraryRoot,
|
||||
): ContextPackEntry {
|
||||
const raw = readFileSync(manifestPath, "utf-8");
|
||||
const manifest = parseManifest(raw, manifestPath);
|
||||
|
||||
let mostRecentMtime = 0;
|
||||
try {
|
||||
mostRecentMtime = statSync(manifestPath).mtimeMs;
|
||||
} catch { /* unreadable manifest stat → fall back to 0 */ }
|
||||
|
||||
const files: ContextPackEntryFile[] = manifest.files.map((mf) => {
|
||||
const abs = join(packDir, mf.path);
|
||||
let bytes: number | null = null;
|
||||
let mtime = 0;
|
||||
try {
|
||||
const st = statSync(abs);
|
||||
bytes = st.size;
|
||||
mtime = st.mtimeMs;
|
||||
} catch {
|
||||
bytes = null;
|
||||
}
|
||||
if (mtime > mostRecentMtime) mostRecentMtime = mtime;
|
||||
return {
|
||||
path: mf.path,
|
||||
role: mf.role,
|
||||
summary: mf.summary ?? null,
|
||||
absolutePath: bytes === null ? null : abs,
|
||||
bytes,
|
||||
estimatedTokens: bytes === null ? null : estimateTokensFromBytes(bytes),
|
||||
};
|
||||
});
|
||||
|
||||
const derivedEstimatedTokens = files.reduce((acc, f) => acc + (f.estimatedTokens ?? 0), 0);
|
||||
|
||||
return {
|
||||
id: contextPackId(manifest.name, manifest.version),
|
||||
kind: "context-pack",
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
purpose: manifest.purpose ?? null,
|
||||
sourceType: root.sourceType,
|
||||
sourcePath: packDir,
|
||||
relativePath: relative(root.path, packDir) || ".",
|
||||
updatedAt: new Date(mostRecentMtime || Date.now()).toISOString(),
|
||||
manifestEstimatedTokens: typeof manifest.estimatedTokens === "number" ? manifest.estimatedTokens : null,
|
||||
derivedEstimatedTokens,
|
||||
files,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Rig Context / Composable Context Injection v0 (PL-014) — typed primitive
|
||||
// shared types.
|
||||
//
|
||||
// A context_pack is a directory containing `manifest.yaml` + included
|
||||
// markdown / yaml / txt files. Operator-authored, library-discoverable,
|
||||
// reviewable, sendable. Parallel to skills + workflow_specs in shape.
|
||||
//
|
||||
// MVP single-host context: storage filesystem-canonical at
|
||||
// ~/.openrig/context-packs/<name>/ (host) + workspace-local
|
||||
// .openrig/context-packs/<name>/. NO new SQLite tables; library cache
|
||||
// is in-memory at the daemon scope.
|
||||
|
||||
export interface ContextPackManifestFile {
|
||||
/** Relative path within the pack directory. */
|
||||
path: string;
|
||||
/** Free-form operator-defined role (e.g., "prd", "proof-packet",
|
||||
* "architecture-brief"); closed enum at v1+ if patterns emerge. */
|
||||
role: string;
|
||||
/** One-line description rendered in the review pane and bundle frame. */
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface ContextPackManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
purpose?: string;
|
||||
files: ContextPackManifestFile[];
|
||||
/** Operator-supplied estimate (used as a hint when no per-file
|
||||
* estimate is available); the library service computes a derived
|
||||
* `derivedEstimatedTokens` from actual file sizes for display. */
|
||||
estimatedTokens?: number;
|
||||
}
|
||||
|
||||
export type ContextPackSourceType = "builtin" | "user_file" | "workspace";
|
||||
|
||||
/** Library-shaped record emitted by ContextPackLibraryService. */
|
||||
export interface ContextPackEntry {
|
||||
/** Stable identifier for routes / UI navigation:
|
||||
* `context-pack:<name>:<version>`. */
|
||||
id: string;
|
||||
kind: "context-pack";
|
||||
name: string;
|
||||
version: string;
|
||||
purpose: string | null;
|
||||
sourceType: ContextPackSourceType;
|
||||
/** Absolute path to the pack directory. */
|
||||
sourcePath: string;
|
||||
/** Path relative to the discovery root that found this pack. */
|
||||
relativePath: string;
|
||||
/** ISO timestamp of the most recent mtime under the pack dir
|
||||
* (manifest.yaml or any included file — whichever is newer). */
|
||||
updatedAt: string;
|
||||
/** Operator-supplied estimate from the manifest, or null. */
|
||||
manifestEstimatedTokens: number | null;
|
||||
/** Daemon-derived estimate from actual file sizes (chars / 4
|
||||
* rounded — matches the existing context-usage convention). */
|
||||
derivedEstimatedTokens: number;
|
||||
/** Per-file metadata projected from the manifest + on-disk reads. */
|
||||
files: ContextPackEntryFile[];
|
||||
}
|
||||
|
||||
export interface ContextPackEntryFile {
|
||||
path: string;
|
||||
role: string;
|
||||
summary: string | null;
|
||||
/** Absolute path to the file on disk; null when manifest references
|
||||
* a file that doesn't exist (the entry surfaces honestly with
|
||||
* bytes=null instead of refusing to index). */
|
||||
absolutePath: string | null;
|
||||
/** File size in bytes; null when missing or unreadable. */
|
||||
bytes: number | null;
|
||||
/** Daemon-derived per-file token estimate (chars / 4 rounded). */
|
||||
estimatedTokens: number | null;
|
||||
}
|
||||
|
||||
export class ContextPackError extends Error {
|
||||
constructor(
|
||||
public readonly code:
|
||||
| "manifest_missing"
|
||||
| "manifest_parse_error"
|
||||
| "manifest_invalid"
|
||||
| "pack_not_found"
|
||||
| "file_outside_pack"
|
||||
| "file_read_failed",
|
||||
message: string,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ContextPackError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Rig Context / Composable Context Injection v0 (PL-014) — manifest
|
||||
// parser.
|
||||
//
|
||||
// Parses ~/.openrig/context-packs/<name>/manifest.yaml into a typed
|
||||
// shape with structured-error rejects on malformed input. Pure
|
||||
// (no fs touches in the parser itself; caller hands in raw YAML).
|
||||
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { ContextPackError, type ContextPackManifest, type ContextPackManifestFile } from "./context-pack-types.js";
|
||||
|
||||
const ALLOWED_FILE_SUFFIXES = [".md", ".markdown", ".yaml", ".yml", ".txt"];
|
||||
|
||||
export function parseManifest(rawYaml: string, sourcePath: string): ContextPackManifest {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseYaml(rawYaml);
|
||||
} catch (err) {
|
||||
throw new ContextPackError(
|
||||
"manifest_parse_error",
|
||||
`manifest at ${sourcePath} is not valid YAML: ${(err as Error).message}`,
|
||||
{ sourcePath },
|
||||
);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new ContextPackError(
|
||||
"manifest_invalid",
|
||||
`manifest at ${sourcePath} must be a YAML object at the root`,
|
||||
{ sourcePath },
|
||||
);
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
const name = obj["name"];
|
||||
if (typeof name !== "string" || name.length === 0) {
|
||||
throw new ContextPackError(
|
||||
"manifest_invalid",
|
||||
`manifest at ${sourcePath} is missing required field 'name' (string)`,
|
||||
{ sourcePath },
|
||||
);
|
||||
}
|
||||
|
||||
// Version may be a number or string in YAML; normalize to string for
|
||||
// round-tripping with library ids and `<name>:<version>` formatting.
|
||||
const versionRaw = obj["version"];
|
||||
if (versionRaw === undefined || versionRaw === null) {
|
||||
throw new ContextPackError(
|
||||
"manifest_invalid",
|
||||
`manifest at ${sourcePath} is missing required field 'version'`,
|
||||
{ sourcePath },
|
||||
);
|
||||
}
|
||||
const version = String(versionRaw);
|
||||
|
||||
const purpose = typeof obj["purpose"] === "string" ? (obj["purpose"] as string) : undefined;
|
||||
|
||||
const filesRaw = obj["files"];
|
||||
if (!Array.isArray(filesRaw)) {
|
||||
throw new ContextPackError(
|
||||
"manifest_invalid",
|
||||
`manifest at ${sourcePath} must declare 'files: [...]' (got: ${typeof filesRaw})`,
|
||||
{ sourcePath },
|
||||
);
|
||||
}
|
||||
const files: ContextPackManifestFile[] = [];
|
||||
for (let i = 0; i < filesRaw.length; i++) {
|
||||
const f = filesRaw[i];
|
||||
if (!f || typeof f !== "object" || Array.isArray(f)) {
|
||||
throw new ContextPackError(
|
||||
"manifest_invalid",
|
||||
`manifest at ${sourcePath} has malformed entry at files[${i}] (must be an object with 'path' + 'role')`,
|
||||
{ sourcePath, index: i },
|
||||
);
|
||||
}
|
||||
const fr = f as Record<string, unknown>;
|
||||
const path = fr["path"];
|
||||
if (typeof path !== "string" || path.length === 0) {
|
||||
throw new ContextPackError(
|
||||
"manifest_invalid",
|
||||
`manifest at ${sourcePath} files[${i}] missing 'path' (string)`,
|
||||
{ sourcePath, index: i },
|
||||
);
|
||||
}
|
||||
if (path.includes("..") || path.startsWith("/")) {
|
||||
throw new ContextPackError(
|
||||
"manifest_invalid",
|
||||
`manifest at ${sourcePath} files[${i}].path '${path}' must be a relative path inside the pack (no '..' segments, no leading '/')`,
|
||||
{ sourcePath, index: i, path },
|
||||
);
|
||||
}
|
||||
const role = fr["role"];
|
||||
if (typeof role !== "string" || role.length === 0) {
|
||||
throw new ContextPackError(
|
||||
"manifest_invalid",
|
||||
`manifest at ${sourcePath} files[${i}] missing 'role' (string)`,
|
||||
{ sourcePath, index: i, path },
|
||||
);
|
||||
}
|
||||
if (!ALLOWED_FILE_SUFFIXES.some((s) => path.endsWith(s))) {
|
||||
throw new ContextPackError(
|
||||
"manifest_invalid",
|
||||
`manifest at ${sourcePath} files[${i}].path '${path}' has an unsupported suffix; allowed: ${ALLOWED_FILE_SUFFIXES.join(", ")}`,
|
||||
{ sourcePath, index: i, path },
|
||||
);
|
||||
}
|
||||
const summary = typeof fr["summary"] === "string" ? (fr["summary"] as string) : undefined;
|
||||
files.push(summary === undefined ? { path, role } : { path, role, summary });
|
||||
}
|
||||
|
||||
const estimatedTokensRaw = obj["estimated_tokens"] ?? obj["estimatedTokens"];
|
||||
const estimatedTokens = typeof estimatedTokensRaw === "number" && Number.isFinite(estimatedTokensRaw)
|
||||
? Math.max(0, Math.floor(estimatedTokensRaw))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
name,
|
||||
version,
|
||||
...(purpose !== undefined ? { purpose } : {}),
|
||||
files,
|
||||
...(estimatedTokens !== undefined ? { estimatedTokens } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Rig Context / Composable Context Injection v0 (PL-014) — daemon HTTP
|
||||
// routes for context_packs.
|
||||
//
|
||||
// Endpoints:
|
||||
// GET /api/context-packs/library — list all packs
|
||||
// POST /api/context-packs/library/sync — re-walk discovery roots
|
||||
// GET /api/context-packs/library/:id — pack manifest + files
|
||||
// GET /api/context-packs/library/:id/preview — assembled bundle (dry-run shape)
|
||||
// POST /api/context-packs/library/:id/send — send to a destination session
|
||||
//
|
||||
// :id is `context-pack:<name>:<version>` — same shape as
|
||||
// SpecLibraryEntry.id for workflows, so URL-encoding rules carry over.
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { ContextPackLibraryService } from "../domain/context-packs/context-pack-library-service.js";
|
||||
import type { SessionTransport } from "../domain/session-transport.js";
|
||||
import { assembleBundle } from "../domain/context-packs/bundle-assembler.js";
|
||||
|
||||
interface SendBody {
|
||||
destinationSession?: string;
|
||||
/** When true, returns the assembled bundle without invoking SessionTransport. */
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export function contextPacksRoutes(): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// GET /library
|
||||
router.get("/library", (c) => {
|
||||
const lib = c.get("contextPackLibrary" as never) as ContextPackLibraryService | undefined;
|
||||
if (!lib) return c.json({ error: "context_pack_library_unavailable" }, 503);
|
||||
return c.json(lib.list());
|
||||
});
|
||||
|
||||
// POST /library/sync
|
||||
router.post("/library/sync", (c) => {
|
||||
const lib = c.get("contextPackLibrary" as never) as ContextPackLibraryService | undefined;
|
||||
if (!lib) return c.json({ error: "context_pack_library_unavailable" }, 503);
|
||||
const result = lib.scan();
|
||||
return c.json({ ...result, entries: lib.list() });
|
||||
});
|
||||
|
||||
// GET /library/:id
|
||||
router.get("/library/:id", (c) => {
|
||||
const lib = c.get("contextPackLibrary" as never) as ContextPackLibraryService | undefined;
|
||||
if (!lib) return c.json({ error: "context_pack_library_unavailable" }, 503);
|
||||
const id = decodeURIComponent(c.req.param("id"));
|
||||
// Guard: literal /sync is handled above; reject as 404 here so a
|
||||
// pack with id="sync" (which can't exist since ids carry a colon)
|
||||
// doesn't shadow the literal.
|
||||
if (id === "sync") return c.notFound();
|
||||
const entry = lib.get(id);
|
||||
if (!entry) return c.json({ error: `Context pack '${id}' not found in library` }, 404);
|
||||
return c.json(entry);
|
||||
});
|
||||
|
||||
// GET /library/:id/preview — assembled bundle (read-only, no send)
|
||||
router.get("/library/:id/preview", (c) => {
|
||||
const lib = c.get("contextPackLibrary" as never) as ContextPackLibraryService | undefined;
|
||||
if (!lib) return c.json({ error: "context_pack_library_unavailable" }, 503);
|
||||
const id = decodeURIComponent(c.req.param("id"));
|
||||
const entry = lib.get(id);
|
||||
if (!entry) return c.json({ error: `Context pack '${id}' not found in library` }, 404);
|
||||
try {
|
||||
const bundle = assembleBundle({ packEntry: entry });
|
||||
return c.json({
|
||||
id,
|
||||
name: entry.name,
|
||||
version: entry.version,
|
||||
bundleText: bundle.text,
|
||||
bundleBytes: bundle.bytes,
|
||||
estimatedTokens: bundle.estimatedTokens,
|
||||
files: bundle.files,
|
||||
missingFiles: bundle.missingFiles,
|
||||
});
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /library/:id/send
|
||||
router.post("/library/:id/send", async (c) => {
|
||||
const lib = c.get("contextPackLibrary" as never) as ContextPackLibraryService | undefined;
|
||||
const transport = c.get("sessionTransport" as never) as SessionTransport | undefined;
|
||||
if (!lib) return c.json({ error: "context_pack_library_unavailable" }, 503);
|
||||
if (!transport) return c.json({ error: "session_transport_unavailable" }, 503);
|
||||
|
||||
const id = decodeURIComponent(c.req.param("id"));
|
||||
const entry = lib.get(id);
|
||||
if (!entry) return c.json({ error: `Context pack '${id}' not found in library` }, 404);
|
||||
|
||||
const body = await c.req.json<SendBody>().catch(() => ({} as SendBody));
|
||||
const destinationSession = body.destinationSession;
|
||||
const dryRun = !!body.dryRun;
|
||||
if (typeof destinationSession !== "string" || destinationSession.length === 0) {
|
||||
return c.json({
|
||||
error: "destinationSession_required",
|
||||
hint: "POST body must include { destinationSession: <session-name> }",
|
||||
}, 400);
|
||||
}
|
||||
|
||||
let bundle;
|
||||
try {
|
||||
bundle = assembleBundle({ packEntry: entry });
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 500);
|
||||
}
|
||||
|
||||
const previewPayload = {
|
||||
id,
|
||||
name: entry.name,
|
||||
version: entry.version,
|
||||
destinationSession,
|
||||
bundleBytes: bundle.bytes,
|
||||
estimatedTokens: bundle.estimatedTokens,
|
||||
files: bundle.files,
|
||||
missingFiles: bundle.missingFiles,
|
||||
dryRun,
|
||||
};
|
||||
|
||||
if (dryRun) {
|
||||
return c.json({ ...previewPayload, bundleText: bundle.text });
|
||||
}
|
||||
|
||||
const result = await transport.send(destinationSession, bundle.text);
|
||||
if (!result.ok) {
|
||||
return c.json({
|
||||
...previewPayload,
|
||||
sent: false,
|
||||
error: result.error ?? result.reason ?? "send_failed",
|
||||
reason: result.reason,
|
||||
}, 502);
|
||||
}
|
||||
return c.json({ ...previewPayload, sent: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import type { AskService } from "./domain/ask-service.js";
|
||||
import { specReviewRoutes } from "./routes/spec-review.js";
|
||||
import { specLibraryRoutes } from "./routes/spec-library.js";
|
||||
import { configRoutes } from "./routes/config.js";
|
||||
import { contextPacksRoutes } from "./routes/context-packs.js";
|
||||
import type { SpecReviewService } from "./domain/spec-review-service.js";
|
||||
import type { SpecLibraryService } from "./domain/spec-library-service.js";
|
||||
import type { ChatRepository } from "./domain/chat-repository.js";
|
||||
@@ -182,6 +183,8 @@ export interface AppDeps {
|
||||
specLibraryService?: SpecLibraryService;
|
||||
/** Workflows in Spec Library v0 — active workflow lens persistence. */
|
||||
activeLensStore?: import("./domain/active-lens-store.js").ActiveLensStore;
|
||||
/** Rig Context / Composable Context Injection v0 (PL-014) — context_packs library service. */
|
||||
contextPackLibrary?: import("./domain/context-packs/context-pack-library-service.js").ContextPackLibraryService;
|
||||
whoamiService?: WhoamiService;
|
||||
contextUsageStore?: import("./domain/context-usage-store.js").ContextUsageStore;
|
||||
contextMonitor?: { pollOnce(): Promise<void> };
|
||||
@@ -359,6 +362,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
c.set("specReviewService" as never, deps.specReviewService);
|
||||
c.set("specLibraryService" as never, deps.specLibraryService);
|
||||
c.set("activeLensStore" as never, deps.activeLensStore);
|
||||
c.set("contextPackLibrary" as never, deps.contextPackLibrary);
|
||||
c.set("whoamiService" as never, deps.whoamiService);
|
||||
c.set("contextUsageStore" as never, deps.contextUsageStore);
|
||||
c.set("contextMonitor" as never, deps.contextMonitor);
|
||||
@@ -401,6 +405,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/specs/review", specReviewRoutes());
|
||||
app.route("/api/specs/library", specLibraryRoutes());
|
||||
app.route("/api/config", configRoutes());
|
||||
app.route("/api/context-packs", contextPacksRoutes());
|
||||
app.route("/api/whoami", whoamiRoutes());
|
||||
app.route("/api/seat", seatRoutes);
|
||||
app.route("/api/rigs/:rigId/chat", chatRoutes());
|
||||
|
||||
@@ -75,6 +75,8 @@ import { WorkflowRuntime } from "./domain/workflow-runtime.js";
|
||||
import { makeWorkflowKeepalivePolicy } from "./domain/policies/workflow-keepalive.js";
|
||||
import { SpecReviewService } from "./domain/spec-review-service.js";
|
||||
import { SpecLibraryService } from "./domain/spec-library-service.js";
|
||||
import { ContextPackLibraryService } from "./domain/context-packs/context-pack-library-service.js";
|
||||
import { SettingsStore as ContextPackSettingsStore } from "./domain/user-settings/settings-store.js";
|
||||
import { WhoamiService } from "./domain/whoami-service.js";
|
||||
import { NodeCmuxService } from "./domain/node-cmux-service.js";
|
||||
import { createApp, type AppDeps } from "./server.js";
|
||||
@@ -545,6 +547,44 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
lib.scan();
|
||||
return lib;
|
||||
})(),
|
||||
contextPackLibrary: (() => {
|
||||
// Rig Context / Composable Context Injection v0 (PL-014).
|
||||
// Discovery roots:
|
||||
// 1. ~/.openrig/context-packs/ (host-global; user_file)
|
||||
// 2. <workspace>/.openrig/context-packs/ (workspace-local; workspace)
|
||||
// Workspace path comes from the workspace.root setting if set;
|
||||
// otherwise inherits from ~/.openrig/workspace/. Both are
|
||||
// optional — empty roots → empty library.
|
||||
const userPacksRoot = getDefaultOpenRigPath("context-packs");
|
||||
try { fs.mkdirSync(userPacksRoot, { recursive: true }); } catch { /* best-effort */ }
|
||||
const roots: Array<{ path: string; sourceType: "builtin" | "user_file" | "workspace" }> = [
|
||||
{ path: userPacksRoot, sourceType: "user_file" },
|
||||
];
|
||||
// Workspace-local context-packs/ — best-effort lookup off the
|
||||
// workspace.root setting. If unset, falls back to the default
|
||||
// workspace path which is the same parent as user packs (skipped
|
||||
// as a duplicate). Last-wins ordering: workspace > user_file
|
||||
// (operator's per-project edits win on collision).
|
||||
try {
|
||||
const settingsStore = new ContextPackSettingsStore();
|
||||
const cfg = settingsStore.resolveConfig();
|
||||
const workspacePacksRoot = nodePath.join(cfg.workspaceRoot, ".openrig", "context-packs");
|
||||
if (workspacePacksRoot !== userPacksRoot && fs.existsSync(workspacePacksRoot)) {
|
||||
roots.push({ path: workspacePacksRoot, sourceType: "workspace" });
|
||||
}
|
||||
} catch {
|
||||
// Settings unavailable in this build path → use only user_file root.
|
||||
}
|
||||
// Bundled built-in packs would live under packages/daemon/context-packs/
|
||||
// when shipped; absent today (no built-in packs at v0).
|
||||
const builtinPacksRoot = nodePath.resolve(import.meta.dirname, "../context-packs");
|
||||
if (fs.existsSync(builtinPacksRoot)) {
|
||||
roots.unshift({ path: builtinPacksRoot, sourceType: "builtin" });
|
||||
}
|
||||
const lib = new ContextPackLibraryService({ roots });
|
||||
lib.scan();
|
||||
return lib;
|
||||
})(),
|
||||
};
|
||||
|
||||
// Copy bundled reference docs to ~/.openrig/reference/ so agents can find them at a stable path
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Rig Context / Composable Context Injection v0 (PL-014) — bundle
|
||||
// assembler tests.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { assembleBundle } from "../src/domain/context-packs/bundle-assembler.js";
|
||||
import type { ContextPackEntry } from "../src/domain/context-packs/context-pack-types.js";
|
||||
|
||||
function makeEntry(opts: Partial<ContextPackEntry> & { files: ContextPackEntry["files"]; purpose?: string | null }): ContextPackEntry {
|
||||
return {
|
||||
id: opts.id ?? "context-pack:test:1",
|
||||
kind: "context-pack",
|
||||
name: opts.name ?? "test",
|
||||
version: opts.version ?? "1",
|
||||
purpose: opts.purpose ?? null,
|
||||
sourceType: opts.sourceType ?? "user_file",
|
||||
sourcePath: opts.sourcePath ?? "/tmp/test",
|
||||
relativePath: opts.relativePath ?? "test",
|
||||
updatedAt: opts.updatedAt ?? "2026-05-04T00:00:00Z",
|
||||
manifestEstimatedTokens: opts.manifestEstimatedTokens ?? null,
|
||||
derivedEstimatedTokens: opts.derivedEstimatedTokens ?? 0,
|
||||
files: opts.files,
|
||||
};
|
||||
}
|
||||
|
||||
describe("assembleBundle", () => {
|
||||
it("frames the bundle with name + version + per-file headers", () => {
|
||||
const entry = makeEntry({
|
||||
name: "alpha",
|
||||
version: "1",
|
||||
purpose: "Alpha pack purpose",
|
||||
files: [
|
||||
{ path: "prd.md", role: "prd", summary: "PRD summary", absolutePath: "/abs/prd.md", bytes: 12, estimatedTokens: 3 },
|
||||
{ path: "notes.md", role: "notes", summary: null, absolutePath: "/abs/notes.md", bytes: 5, estimatedTokens: 2 },
|
||||
],
|
||||
});
|
||||
const reads: Record<string, string> = {
|
||||
"/abs/prd.md": "## PRD body",
|
||||
"/abs/notes.md": "Note 1",
|
||||
};
|
||||
const bundle = assembleBundle({
|
||||
packEntry: entry,
|
||||
readFile: (p) => reads[p]!,
|
||||
});
|
||||
expect(bundle.text).toContain("# OpenRig Context Pack: alpha v1");
|
||||
expect(bundle.text).toContain("Alpha pack purpose");
|
||||
expect(bundle.text).toContain("## File: prd.md (role: prd) — PRD summary");
|
||||
expect(bundle.text).toContain("## File: notes.md (role: notes)");
|
||||
expect(bundle.text).toContain("## PRD body");
|
||||
expect(bundle.text).toContain("Note 1");
|
||||
expect(bundle.bytes).toBe(Buffer.byteLength(bundle.text, "utf-8"));
|
||||
});
|
||||
|
||||
it("skips files marked missing (absolutePath null) and reports them", () => {
|
||||
const entry = makeEntry({
|
||||
files: [
|
||||
{ path: "present.md", role: "r", summary: null, absolutePath: "/abs/present.md", bytes: 5, estimatedTokens: 2 },
|
||||
{ path: "absent.md", role: "r", summary: null, absolutePath: null, bytes: null, estimatedTokens: null },
|
||||
],
|
||||
});
|
||||
const reads: Record<string, string> = { "/abs/present.md": "content" };
|
||||
const bundle = assembleBundle({ packEntry: entry, readFile: (p) => reads[p]! });
|
||||
expect(bundle.files).toHaveLength(1);
|
||||
expect(bundle.files[0]!.path).toBe("present.md");
|
||||
expect(bundle.missingFiles).toEqual([{ path: "absent.md", role: "r" }]);
|
||||
expect(bundle.text).not.toContain("absent.md");
|
||||
});
|
||||
|
||||
it("estimates token count from byte count of the assembled text", () => {
|
||||
const entry = makeEntry({
|
||||
files: [
|
||||
{ path: "a.md", role: "r", summary: null, absolutePath: "/abs/a.md", bytes: 100, estimatedTokens: 25 },
|
||||
],
|
||||
});
|
||||
const bundle = assembleBundle({
|
||||
packEntry: entry,
|
||||
readFile: () => "x".repeat(100),
|
||||
});
|
||||
// bundle text ≈ headers + 100 chars ; estimateTokens = ceil(bytes / 4)
|
||||
expect(bundle.estimatedTokens).toBeGreaterThanOrEqual(25);
|
||||
});
|
||||
|
||||
it("preserves operator-supplied purpose verbatim (trimmed)", () => {
|
||||
const entry = makeEntry({
|
||||
purpose: " Multi-line purpose\nthat spans ",
|
||||
files: [],
|
||||
});
|
||||
const bundle = assembleBundle({ packEntry: entry, readFile: () => "" });
|
||||
expect(bundle.text).toContain("Multi-line purpose\nthat spans");
|
||||
});
|
||||
|
||||
it("handles a pack with no files (empty bundle, no crash)", () => {
|
||||
const entry = makeEntry({ files: [] });
|
||||
const bundle = assembleBundle({ packEntry: entry, readFile: () => "" });
|
||||
expect(bundle.files).toEqual([]);
|
||||
expect(bundle.missingFiles).toEqual([]);
|
||||
expect(bundle.text).toContain("# OpenRig Context Pack: test v1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
// Rig Context / Composable Context Injection v0 (PL-014) — library
|
||||
// service tests.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
ContextPackLibraryService,
|
||||
contextPackId,
|
||||
parseContextPackId,
|
||||
estimateTokensFromBytes,
|
||||
} from "../src/domain/context-packs/context-pack-library-service.js";
|
||||
|
||||
function writePack(root: string, name: string, manifest: string, files: Record<string, string>) {
|
||||
const dir = join(root, name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "manifest.yaml"), manifest);
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
writeFileSync(join(dir, path), content);
|
||||
}
|
||||
}
|
||||
|
||||
describe("ContextPackLibraryService", () => {
|
||||
let tmp: string;
|
||||
let userRoot: string;
|
||||
let workspaceRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), "context-pack-lib-"));
|
||||
userRoot = join(tmp, "user");
|
||||
workspaceRoot = join(tmp, "workspace");
|
||||
mkdirSync(userRoot, { recursive: true });
|
||||
mkdirSync(workspaceRoot, { recursive: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("scans a pack and emits a normalized entry", () => {
|
||||
writePack(userRoot, "smoke", `
|
||||
name: smoke
|
||||
version: 1
|
||||
purpose: Smoke pack
|
||||
files:
|
||||
- path: notes.md
|
||||
role: notes
|
||||
summary: Smoke notes
|
||||
`, { "notes.md": "# Smoke\n\nHello world." });
|
||||
|
||||
const lib = new ContextPackLibraryService({
|
||||
roots: [{ path: userRoot, sourceType: "user_file" }],
|
||||
});
|
||||
const result = lib.scan();
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.errors).toEqual([]);
|
||||
const entries = lib.list();
|
||||
expect(entries).toHaveLength(1);
|
||||
const entry = entries[0]!;
|
||||
expect(entry.id).toBe(contextPackId("smoke", "1"));
|
||||
expect(entry.name).toBe("smoke");
|
||||
expect(entry.kind).toBe("context-pack");
|
||||
expect(entry.purpose).toBe("Smoke pack");
|
||||
expect(entry.files).toHaveLength(1);
|
||||
expect(entry.files[0]!.bytes).toBeGreaterThan(0);
|
||||
expect(entry.files[0]!.estimatedTokens).toBeGreaterThan(0);
|
||||
expect(entry.derivedEstimatedTokens).toBe(entry.files[0]!.estimatedTokens);
|
||||
});
|
||||
|
||||
it("surfaces missing files with bytes=null instead of refusing the entry", () => {
|
||||
writePack(userRoot, "missing", `
|
||||
name: missing
|
||||
version: 1
|
||||
files:
|
||||
- path: present.md
|
||||
role: r
|
||||
- path: absent.md
|
||||
role: r
|
||||
`, { "present.md": "data" });
|
||||
const lib = new ContextPackLibraryService({
|
||||
roots: [{ path: userRoot, sourceType: "user_file" }],
|
||||
});
|
||||
lib.scan();
|
||||
const entry = lib.getByNameVersion("missing", "1")!;
|
||||
expect(entry).toBeDefined();
|
||||
const present = entry.files.find((f) => f.path === "present.md")!;
|
||||
const absent = entry.files.find((f) => f.path === "absent.md")!;
|
||||
expect(present.bytes).toBeGreaterThan(0);
|
||||
expect(absent.bytes).toBeNull();
|
||||
expect(absent.estimatedTokens).toBeNull();
|
||||
});
|
||||
|
||||
it("workspace root wins on collision (last in roots array)", () => {
|
||||
const sameManifest = `
|
||||
name: collision
|
||||
version: 1
|
||||
files:
|
||||
- path: notes.md
|
||||
role: r
|
||||
`;
|
||||
writePack(userRoot, "collision", sameManifest, { "notes.md": "user content" });
|
||||
writePack(workspaceRoot, "collision", sameManifest, { "notes.md": "workspace content" });
|
||||
const lib = new ContextPackLibraryService({
|
||||
roots: [
|
||||
{ path: userRoot, sourceType: "user_file" },
|
||||
{ path: workspaceRoot, sourceType: "workspace" },
|
||||
],
|
||||
});
|
||||
lib.scan();
|
||||
const entry = lib.getByNameVersion("collision", "1")!;
|
||||
expect(entry.sourceType).toBe("workspace");
|
||||
expect(entry.sourcePath).toContain("/workspace/");
|
||||
});
|
||||
|
||||
it("captures parse errors instead of throwing them out of scan", () => {
|
||||
writePack(userRoot, "broken", "{not valid yaml", { "notes.md": "x" });
|
||||
const lib = new ContextPackLibraryService({
|
||||
roots: [{ path: userRoot, sourceType: "user_file" }],
|
||||
});
|
||||
const result = lib.scan();
|
||||
expect(result.count).toBe(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]!.error).toContain("manifest_parse_error");
|
||||
});
|
||||
|
||||
it("ignores directories without manifest.yaml", () => {
|
||||
mkdirSync(join(userRoot, "not-a-pack"));
|
||||
const lib = new ContextPackLibraryService({
|
||||
roots: [{ path: userRoot, sourceType: "user_file" }],
|
||||
});
|
||||
const result = lib.scan();
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
|
||||
it("re-scan reflects filesystem edits (workspace-surface reconciliation)", () => {
|
||||
writePack(userRoot, "evolve", `
|
||||
name: evolve
|
||||
version: 1
|
||||
files:
|
||||
- path: a.md
|
||||
role: r
|
||||
`, { "a.md": "initial" });
|
||||
const lib = new ContextPackLibraryService({
|
||||
roots: [{ path: userRoot, sourceType: "user_file" }],
|
||||
});
|
||||
lib.scan();
|
||||
expect(lib.list()).toHaveLength(1);
|
||||
// Operator edits the manifest to bump version.
|
||||
writeFileSync(join(userRoot, "evolve", "manifest.yaml"), `
|
||||
name: evolve
|
||||
version: 2
|
||||
files:
|
||||
- path: a.md
|
||||
role: r
|
||||
`);
|
||||
lib.scan();
|
||||
const list = lib.list();
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]!.version).toBe("2");
|
||||
});
|
||||
|
||||
it("resolveFileWithinPack rejects path-traversal attempts", () => {
|
||||
writePack(userRoot, "guard", `
|
||||
name: guard
|
||||
version: 1
|
||||
files:
|
||||
- path: notes.md
|
||||
role: r
|
||||
`, { "notes.md": "x" });
|
||||
const lib = new ContextPackLibraryService({
|
||||
roots: [{ path: userRoot, sourceType: "user_file" }],
|
||||
});
|
||||
lib.scan();
|
||||
const entry = lib.getByNameVersion("guard", "1")!;
|
||||
expect(() => lib.resolveFileWithinPack(entry, "../etc/passwd")).toThrow(/inside the pack/);
|
||||
expect(() => lib.resolveFileWithinPack(entry, "/abs")).toThrow(/inside the pack/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contextPackId / parseContextPackId", () => {
|
||||
it("encodes and decodes name:version", () => {
|
||||
expect(contextPackId("foo", "1")).toBe("context-pack:foo:1");
|
||||
expect(parseContextPackId("context-pack:foo:1")).toEqual({ name: "foo", version: "1" });
|
||||
});
|
||||
|
||||
it("splits on the LAST colon so names with colons round-trip", () => {
|
||||
const id = contextPackId("rsi:v2", "3");
|
||||
expect(parseContextPackId(id)).toEqual({ name: "rsi:v2", version: "3" });
|
||||
});
|
||||
|
||||
it("returns null for non-context-pack ids", () => {
|
||||
expect(parseContextPackId("workflow:foo:1")).toBeNull();
|
||||
expect(parseContextPackId("context-pack:no-version")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateTokensFromBytes", () => {
|
||||
it("uses the chars/4 heuristic", () => {
|
||||
expect(estimateTokensFromBytes(0)).toBe(0);
|
||||
expect(estimateTokensFromBytes(4)).toBe(1);
|
||||
expect(estimateTokensFromBytes(7)).toBe(2);
|
||||
expect(estimateTokensFromBytes(100)).toBe(25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// Rig Context / Composable Context Injection v0 (PL-014) — manifest
|
||||
// parser unit tests.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseManifest } from "../src/domain/context-packs/manifest-parser.js";
|
||||
import { ContextPackError } from "../src/domain/context-packs/context-pack-types.js";
|
||||
|
||||
const validManifest = `
|
||||
name: pl-005-priming
|
||||
version: 1
|
||||
purpose: Priming for PL-005 Phase A
|
||||
files:
|
||||
- path: prd.md
|
||||
role: prd
|
||||
summary: Phase A PRD
|
||||
- path: proof.md
|
||||
role: proof-packet
|
||||
estimated_tokens: 500
|
||||
`;
|
||||
|
||||
describe("parseManifest", () => {
|
||||
it("parses a valid manifest into the typed shape", () => {
|
||||
const m = parseManifest(validManifest, "/test/manifest.yaml");
|
||||
expect(m.name).toBe("pl-005-priming");
|
||||
expect(m.version).toBe("1");
|
||||
expect(m.purpose).toContain("Priming for PL-005");
|
||||
expect(m.files).toHaveLength(2);
|
||||
expect(m.files[0]).toEqual({ path: "prd.md", role: "prd", summary: "Phase A PRD" });
|
||||
expect(m.files[1]).toEqual({ path: "proof.md", role: "proof-packet" });
|
||||
expect(m.estimatedTokens).toBe(500);
|
||||
});
|
||||
|
||||
it("normalizes numeric versions to strings", () => {
|
||||
const m = parseManifest("name: x\nversion: 2\nfiles: []", "/x.yaml");
|
||||
expect(m.version).toBe("2");
|
||||
});
|
||||
|
||||
it("rejects non-YAML content with manifest_parse_error", () => {
|
||||
expect(() => parseManifest("{not valid", "/x.yaml")).toThrow(ContextPackError);
|
||||
try {
|
||||
parseManifest("{not valid", "/x.yaml");
|
||||
} catch (err) {
|
||||
expect((err as ContextPackError).code).toBe("manifest_parse_error");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing name", () => {
|
||||
expect(() => parseManifest("version: 1\nfiles: []", "/x.yaml")).toThrow(/name/);
|
||||
});
|
||||
|
||||
it("rejects missing version", () => {
|
||||
expect(() => parseManifest("name: x\nfiles: []", "/x.yaml")).toThrow(/version/);
|
||||
});
|
||||
|
||||
it("rejects malformed files array", () => {
|
||||
expect(() => parseManifest("name: x\nversion: 1\nfiles: not-an-array", "/x.yaml")).toThrow(/files/);
|
||||
});
|
||||
|
||||
it("rejects file entry with .. in path (escape attempt)", () => {
|
||||
const bad = "name: x\nversion: 1\nfiles:\n - path: ../escape.md\n role: notes\n";
|
||||
expect(() => parseManifest(bad, "/x.yaml")).toThrow(/relative path inside the pack/);
|
||||
});
|
||||
|
||||
it("rejects file entry with absolute path", () => {
|
||||
const bad = "name: x\nversion: 1\nfiles:\n - path: /etc/passwd\n role: notes\n";
|
||||
expect(() => parseManifest(bad, "/x.yaml")).toThrow(/relative path inside the pack/);
|
||||
});
|
||||
|
||||
it("rejects file entry with unsupported suffix", () => {
|
||||
const bad = "name: x\nversion: 1\nfiles:\n - path: code.ts\n role: code\n";
|
||||
expect(() => parseManifest(bad, "/x.yaml")).toThrow(/unsupported suffix/);
|
||||
});
|
||||
|
||||
it("rejects file entry missing role", () => {
|
||||
const bad = "name: x\nversion: 1\nfiles:\n - path: notes.md\n";
|
||||
expect(() => parseManifest(bad, "/x.yaml")).toThrow(/missing 'role'/);
|
||||
});
|
||||
|
||||
it("accepts allowed suffixes md/markdown/yaml/yml/txt", () => {
|
||||
const ok = `name: x
|
||||
version: 1
|
||||
files:
|
||||
- { path: a.md, role: r }
|
||||
- { path: b.markdown, role: r }
|
||||
- { path: c.yaml, role: r }
|
||||
- { path: d.yml, role: r }
|
||||
- { path: e.txt, role: r }
|
||||
`;
|
||||
const m = parseManifest(ok, "/x.yaml");
|
||||
expect(m.files).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("ignores estimated_tokens when not a finite number", () => {
|
||||
const m = parseManifest("name: x\nversion: 1\nfiles: []\nestimated_tokens: 'not-a-number'", "/x.yaml");
|
||||
expect(m.estimatedTokens).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
// Rig Context / Composable Context Injection v0 (PL-014) — daemon
|
||||
// HTTP route tests.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { ContextPackLibraryService } from "../src/domain/context-packs/context-pack-library-service.js";
|
||||
import { contextPacksRoutes } from "../src/routes/context-packs.js";
|
||||
|
||||
interface FakeSendResult {
|
||||
ok: boolean;
|
||||
sessionName: string;
|
||||
reason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
class FakeSessionTransport {
|
||||
public calls: Array<{ sessionName: string; text: string }> = [];
|
||||
public response: FakeSendResult = { ok: true, sessionName: "x" };
|
||||
async send(sessionName: string, text: string): Promise<FakeSendResult> {
|
||||
this.calls.push({ sessionName, text });
|
||||
return { ...this.response, sessionName };
|
||||
}
|
||||
}
|
||||
|
||||
function writePack(root: string, name: string, manifest: string, files: Record<string, string>) {
|
||||
const dir = join(root, name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "manifest.yaml"), manifest);
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
writeFileSync(join(dir, path), content);
|
||||
}
|
||||
}
|
||||
|
||||
describe("context-packs routes (PL-014)", () => {
|
||||
let tmp: string;
|
||||
let libRoot: string;
|
||||
let lib: ContextPackLibraryService;
|
||||
let transport: FakeSessionTransport;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), "context-pack-routes-"));
|
||||
libRoot = join(tmp, "lib");
|
||||
mkdirSync(libRoot, { recursive: true });
|
||||
lib = new ContextPackLibraryService({
|
||||
roots: [{ path: libRoot, sourceType: "user_file" }],
|
||||
});
|
||||
transport = new FakeSessionTransport();
|
||||
});
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
function buildApp(opts?: { withTransport?: boolean; withLib?: boolean }): Hono {
|
||||
const app = new Hono();
|
||||
const withTransport = opts?.withTransport !== false;
|
||||
const withLib = opts?.withLib !== false;
|
||||
app.use("*", async (c, next) => {
|
||||
if (withLib) c.set("contextPackLibrary" as never, lib);
|
||||
if (withTransport) c.set("sessionTransport" as never, transport);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/context-packs", contextPacksRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
it("GET /library returns 503 when service is not wired", async () => {
|
||||
const app = buildApp({ withLib: false });
|
||||
const res = await app.request("/api/context-packs/library");
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("GET /library returns the indexed packs", async () => {
|
||||
writePack(libRoot, "smoke", `
|
||||
name: smoke
|
||||
version: 1
|
||||
purpose: Smoke
|
||||
files:
|
||||
- path: notes.md
|
||||
role: notes
|
||||
`, { "notes.md": "Hello" });
|
||||
lib.scan();
|
||||
const app = buildApp();
|
||||
const res = await app.request("/api/context-packs/library");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as Array<{ name: string }>;
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]!.name).toBe("smoke");
|
||||
});
|
||||
|
||||
it("POST /library/sync re-indexes and returns the count + entries", async () => {
|
||||
writePack(libRoot, "p1", `
|
||||
name: p1
|
||||
version: 1
|
||||
files:
|
||||
- path: a.md
|
||||
role: r
|
||||
`, { "a.md": "x" });
|
||||
const app = buildApp();
|
||||
const res = await app.request("/api/context-packs/library/sync", { method: "POST" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { count: number; entries: Array<{ name: string }> };
|
||||
expect(body.count).toBe(1);
|
||||
expect(body.entries[0]!.name).toBe("p1");
|
||||
});
|
||||
|
||||
it("GET /library/:id returns the pack manifest", async () => {
|
||||
writePack(libRoot, "p1", `
|
||||
name: p1
|
||||
version: 1
|
||||
files:
|
||||
- path: a.md
|
||||
role: r
|
||||
`, { "a.md": "content" });
|
||||
lib.scan();
|
||||
const app = buildApp();
|
||||
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:p1:1")}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { name: string; files: unknown[] };
|
||||
expect(body.name).toBe("p1");
|
||||
expect(body.files).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("GET /library/:id returns 404 for unknown id", async () => {
|
||||
const app = buildApp();
|
||||
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:missing:1")}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /library/:id/preview returns the assembled bundle", async () => {
|
||||
writePack(libRoot, "preview-pack", `
|
||||
name: preview-pack
|
||||
version: 1
|
||||
purpose: Preview test
|
||||
files:
|
||||
- path: a.md
|
||||
role: r
|
||||
`, { "a.md": "BODY-A" });
|
||||
lib.scan();
|
||||
const app = buildApp();
|
||||
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:preview-pack:1")}/preview`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { bundleText: string; bundleBytes: number; missingFiles: unknown[] };
|
||||
expect(body.bundleText).toContain("# OpenRig Context Pack: preview-pack v1");
|
||||
expect(body.bundleText).toContain("BODY-A");
|
||||
expect(body.bundleBytes).toBeGreaterThan(0);
|
||||
expect(body.missingFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it("POST /library/:id/send dry-run returns the bundle without invoking transport", async () => {
|
||||
writePack(libRoot, "dry", `
|
||||
name: dry
|
||||
version: 1
|
||||
files:
|
||||
- path: a.md
|
||||
role: r
|
||||
`, { "a.md": "dry content" });
|
||||
lib.scan();
|
||||
const app = buildApp();
|
||||
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:dry:1")}/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ destinationSession: "test@rig", dryRun: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { dryRun: boolean; bundleText?: string; sent?: boolean };
|
||||
expect(body.dryRun).toBe(true);
|
||||
expect(body.bundleText).toContain("dry content");
|
||||
expect(body.sent).toBeUndefined();
|
||||
expect(transport.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("POST /library/:id/send invokes SessionTransport.send with the assembled bundle", async () => {
|
||||
writePack(libRoot, "real", `
|
||||
name: real
|
||||
version: 1
|
||||
files:
|
||||
- path: a.md
|
||||
role: r
|
||||
`, { "a.md": "real content" });
|
||||
lib.scan();
|
||||
const app = buildApp();
|
||||
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:real:1")}/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ destinationSession: "driver@rig" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { sent: boolean };
|
||||
expect(body.sent).toBe(true);
|
||||
expect(transport.calls).toHaveLength(1);
|
||||
expect(transport.calls[0]!.sessionName).toBe("driver@rig");
|
||||
expect(transport.calls[0]!.text).toContain("real content");
|
||||
});
|
||||
|
||||
it("POST /library/:id/send 502s when SessionTransport reports failure", async () => {
|
||||
writePack(libRoot, "fail", `
|
||||
name: fail
|
||||
version: 1
|
||||
files:
|
||||
- path: a.md
|
||||
role: r
|
||||
`, { "a.md": "x" });
|
||||
lib.scan();
|
||||
transport.response = { ok: false, sessionName: "x", reason: "session_missing", error: "Session not found" };
|
||||
const app = buildApp();
|
||||
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:fail:1")}/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ destinationSession: "missing@rig" }),
|
||||
});
|
||||
expect(res.status).toBe(502);
|
||||
const body = await res.json() as { error: string; sent: boolean };
|
||||
expect(body.error).toContain("Session not found");
|
||||
expect(body.sent).toBe(false);
|
||||
});
|
||||
|
||||
it("POST /library/:id/send 400s without destinationSession", async () => {
|
||||
writePack(libRoot, "no-dest", `
|
||||
name: no-dest
|
||||
version: 1
|
||||
files:
|
||||
- path: a.md
|
||||
role: r
|
||||
`, { "a.md": "x" });
|
||||
lib.scan();
|
||||
const app = buildApp();
|
||||
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:no-dest:1")}/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /library/:id/send 503s when SessionTransport is missing from context", async () => {
|
||||
writePack(libRoot, "no-transport", `
|
||||
name: no-transport
|
||||
version: 1
|
||||
files:
|
||||
- path: a.md
|
||||
role: r
|
||||
`, { "a.md": "x" });
|
||||
lib.scan();
|
||||
const app = buildApp({ withTransport: false });
|
||||
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:no-transport:1")}/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ destinationSession: "x" }),
|
||||
});
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,13 @@ import {
|
||||
type LibraryAgentReview,
|
||||
type LibraryWorkflowReview,
|
||||
} from "../hooks/useSpecLibrary.js";
|
||||
import {
|
||||
useContextPackLibrary,
|
||||
useContextPackPreview,
|
||||
useContextPackSend,
|
||||
type ContextPackEntry,
|
||||
} from "../hooks/useContextPackLibrary.js";
|
||||
import { usePsEntries } from "../hooks/usePsEntries.js";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
WorkflowHeader,
|
||||
@@ -189,6 +196,17 @@ function LibraryRigReviewContent({ review }: { review: LibraryRigReview }) {
|
||||
}
|
||||
|
||||
export function LibraryReview({ entryId }: LibraryReviewProps) {
|
||||
// PL-014: context_packs live at /api/context-packs/library and have
|
||||
// an id prefix of "context-pack:". Dispatch to the pack-specific
|
||||
// review page before invoking useLibraryReview (which would 404
|
||||
// against the spec-library route for context-pack ids).
|
||||
if (entryId.startsWith("context-pack:")) {
|
||||
return <LibraryContextPackReviewPage entryId={entryId} />;
|
||||
}
|
||||
return <LibrarySpecReview entryId={entryId} />;
|
||||
}
|
||||
|
||||
function LibrarySpecReview({ entryId }: LibraryReviewProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data: review, isLoading, error } = useLibraryReview(entryId);
|
||||
|
||||
@@ -465,3 +483,242 @@ function LibraryWorkflowReviewPage({ review }: { review: LibraryWorkflowReview }
|
||||
</WorkspacePage>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Rig Context / Composable Context Injection v0 (PL-014):
|
||||
// context_pack review page ---
|
||||
|
||||
function LibraryContextPackReviewPage({ entryId }: { entryId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: packs = [], isLoading: packsLoading, error: packsError } = useContextPackLibrary();
|
||||
const entry = packs.find((p) => p.id === entryId) ?? null;
|
||||
const { data: preview, isLoading: previewLoading } = useContextPackPreview(entry ? entryId : null);
|
||||
|
||||
if (packsLoading) {
|
||||
return (
|
||||
<WorkspacePage>
|
||||
<div className="font-mono text-[10px] text-stone-400">Loading context pack…</div>
|
||||
</WorkspacePage>
|
||||
);
|
||||
}
|
||||
if (packsError || !entry) {
|
||||
return (
|
||||
<WorkspacePage>
|
||||
<div data-testid="library-review-error" className="space-y-4">
|
||||
<WorkflowHeader
|
||||
eyebrow="Library"
|
||||
title="Context Pack Not Found"
|
||||
description={(packsError as Error)?.message ?? `No context pack with id ${entryId}.`}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back to Specs</Button>
|
||||
</div>
|
||||
</WorkspacePage>
|
||||
);
|
||||
}
|
||||
|
||||
return <ContextPackReviewBody entry={entry} preview={preview} previewLoading={previewLoading} />;
|
||||
}
|
||||
|
||||
function ContextPackReviewBody({
|
||||
entry,
|
||||
preview,
|
||||
previewLoading,
|
||||
}: {
|
||||
entry: ContextPackEntry;
|
||||
preview: ReturnType<typeof useContextPackPreview>["data"];
|
||||
previewLoading: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [showSendPicker, setShowSendPicker] = useState(false);
|
||||
const [destinationSession, setDestinationSession] = useState<string>("");
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [sendStatus, setSendStatus] = useState<"idle" | "dry-run-shown" | "sent">("idle");
|
||||
const sendMutation = useContextPackSend();
|
||||
const { data: psEntries = [] } = usePsEntries();
|
||||
|
||||
const runningSessions: string[] = (() => {
|
||||
const sessions: string[] = [];
|
||||
for (const rig of psEntries) {
|
||||
const nodes = (rig as { nodes?: Array<{ canonicalSessionName?: string | null; sessionStatus?: string | null }> }).nodes ?? [];
|
||||
for (const n of nodes) {
|
||||
if (n.canonicalSessionName && n.sessionStatus === "running") sessions.push(n.canonicalSessionName);
|
||||
}
|
||||
}
|
||||
return sessions.sort();
|
||||
})();
|
||||
|
||||
const onDryRun = async () => {
|
||||
setSendError(null);
|
||||
if (!destinationSession) {
|
||||
setSendError("Pick a destination session first.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendMutation.mutateAsync({ id: entry.id, destinationSession, dryRun: true });
|
||||
setSendStatus("dry-run-shown");
|
||||
} catch (err) {
|
||||
setSendError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const onSend = async () => {
|
||||
setSendError(null);
|
||||
if (!destinationSession) {
|
||||
setSendError("Pick a destination session first.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendMutation.mutateAsync({ id: entry.id, destinationSession, dryRun: false });
|
||||
setSendStatus("sent");
|
||||
} catch (err) {
|
||||
setSendError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspacePage>
|
||||
<div data-testid="library-review-context-pack" className="space-y-4">
|
||||
<WorkflowHeader
|
||||
eyebrow={`Library — Context Pack${entry.sourceType === "builtin" ? " (built-in)" : ""}`}
|
||||
title={entry.name}
|
||||
description={entry.purpose ?? "Operator-authored composable context bundle."}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="context-pack-send-button"
|
||||
onClick={() => setShowSendPicker((v) => !v)}
|
||||
>
|
||||
{showSendPicker ? "Hide Send" : "Send to seat"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back to Specs</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<WorkflowSummaryGrid>
|
||||
<WorkflowSummaryCard label="Version" value={entry.version} testId="lib-pack-version" />
|
||||
<WorkflowSummaryCard label="Files" value={entry.files.length} testId="lib-pack-files" />
|
||||
<WorkflowSummaryCard
|
||||
label="Tokens (~)"
|
||||
value={String(entry.derivedEstimatedTokens)}
|
||||
testId="lib-pack-tokens"
|
||||
/>
|
||||
<WorkflowSummaryCard label="Source" value={entry.sourceType} testId="lib-pack-source" />
|
||||
</WorkflowSummaryGrid>
|
||||
|
||||
<div data-testid="lib-pack-source-path" className="font-mono text-[9px] text-stone-500">
|
||||
path: {entry.sourcePath}
|
||||
</div>
|
||||
|
||||
<section className="border border-stone-300/40 bg-white/8">
|
||||
<header className="border-b border-stone-200 bg-stone-50 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.10em] text-stone-600">
|
||||
Files
|
||||
</header>
|
||||
<ul data-testid="lib-pack-file-list" className="divide-y divide-stone-100">
|
||||
{entry.files.map((f) => {
|
||||
const missing = f.bytes === null;
|
||||
return (
|
||||
<li
|
||||
key={f.path}
|
||||
data-testid={`lib-pack-file-${f.path}`}
|
||||
data-missing={missing ? "true" : "false"}
|
||||
className={`px-3 py-2 font-mono text-[10px] ${missing ? "text-red-700" : "text-stone-800"}`}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-bold truncate">{f.path}</span>
|
||||
<span className="font-mono text-[8px] text-stone-500 shrink-0">
|
||||
role: {f.role}
|
||||
{missing
|
||||
? " · MISSING"
|
||||
: ` · ${f.bytes}B · ~${f.estimatedTokens} tokens`}
|
||||
</span>
|
||||
</div>
|
||||
{f.summary && (
|
||||
<div className="mt-0.5 text-stone-600 text-[9px]">{f.summary}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{showSendPicker && (
|
||||
<section data-testid="context-pack-send-modal" className="border border-stone-400 bg-white px-3 py-3 space-y-2">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.10em] text-stone-700">Send to seat</div>
|
||||
<select
|
||||
data-testid="context-pack-send-session"
|
||||
className="w-full font-mono text-[10px] border border-stone-300 px-2 py-1"
|
||||
value={destinationSession}
|
||||
onChange={(e) => setDestinationSession(e.target.value)}
|
||||
>
|
||||
<option value="">Pick a running session…</option>
|
||||
{runningSessions.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="context-pack-send-dry-run"
|
||||
onClick={() => void onDryRun()}
|
||||
disabled={sendMutation.isPending}
|
||||
>
|
||||
{sendMutation.isPending ? "…" : "Dry run"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
data-testid="context-pack-send-confirm"
|
||||
onClick={() => void onSend()}
|
||||
disabled={sendMutation.isPending || !destinationSession}
|
||||
>
|
||||
{sendMutation.isPending ? "Sending…" : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
{sendError && (
|
||||
<div data-testid="context-pack-send-error" className="font-mono text-[9px] text-red-600">{sendError}</div>
|
||||
)}
|
||||
{sendStatus === "sent" && (
|
||||
<div data-testid="context-pack-send-success" className="font-mono text-[9px] text-emerald-700">
|
||||
Sent to {destinationSession}.
|
||||
</div>
|
||||
)}
|
||||
{sendStatus === "dry-run-shown" && sendMutation.data?.bundleText && (
|
||||
<pre
|
||||
data-testid="context-pack-send-bundle-preview"
|
||||
className="font-mono text-[9px] bg-stone-50 border border-stone-200 px-2 py-1 max-h-64 overflow-y-auto whitespace-pre-wrap"
|
||||
>
|
||||
{sendMutation.data.bundleText}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="border border-stone-300/40 bg-white/8">
|
||||
<header className="border-b border-stone-200 bg-stone-50 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.10em] text-stone-600">
|
||||
Bundle preview
|
||||
</header>
|
||||
{previewLoading && (
|
||||
<div className="px-3 py-2 font-mono text-[9px] text-stone-400">Loading bundle…</div>
|
||||
)}
|
||||
{preview && (
|
||||
<>
|
||||
{preview.missingFiles.length > 0 && (
|
||||
<div data-testid="lib-pack-missing-warning" className="px-3 py-2 font-mono text-[9px] text-red-700 border-b border-stone-200">
|
||||
Warning: {preview.missingFiles.length} file{preview.missingFiles.length === 1 ? "" : "s"} referenced by manifest but missing on disk.
|
||||
</div>
|
||||
)}
|
||||
<pre
|
||||
data-testid="lib-pack-bundle-text"
|
||||
className="font-mono text-[9px] text-stone-800 bg-stone-50 px-3 py-2 max-h-96 overflow-y-auto whitespace-pre-wrap"
|
||||
>
|
||||
{preview.bundleText}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</WorkspacePage>
|
||||
);
|
||||
}
|
||||
@@ -3,36 +3,55 @@ import { useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSpecsWorkspace, type SpecsDraft } from "./SpecsWorkspace.js";
|
||||
import { useSpecLibrary, useLibraryReview, type SpecLibraryEntry } from "../hooks/useSpecLibrary.js";
|
||||
import { useContextPackLibrary, type ContextPackEntry } from "../hooks/useContextPackLibrary.js";
|
||||
import { usePsEntries } from "../hooks/usePsEntries.js";
|
||||
import { useExpandRig, useRemoveLibrarySpec, useRenameLibrarySpec, type ExpandRigResult } from "../hooks/mutations.js";
|
||||
import { ExpansionOutcome } from "./ExpansionOutcome.js";
|
||||
|
||||
type LibraryFilter = "all" | "apps" | "rigs" | "agents" | "workflows";
|
||||
type LibraryFilter = "all" | "apps" | "rigs" | "agents" | "workflows" | "context-packs";
|
||||
|
||||
function deriveStability(entry: SpecLibraryEntry): "Stable" | "Experimental" | "Community" {
|
||||
// PL-014: unified entry shape covers both spec library entries and
|
||||
// context-pack entries — both are filesystem-canonical, library-
|
||||
// discoverable, reviewable. Discriminated by kind.
|
||||
type UnifiedLibraryEntry = SpecLibraryEntry | (ContextPackEntry & {
|
||||
summary?: string;
|
||||
hasServices?: undefined;
|
||||
});
|
||||
|
||||
function deriveStability(entry: UnifiedLibraryEntry): "Stable" | "Experimental" | "Community" {
|
||||
if (entry.sourceType !== "builtin") return "Community";
|
||||
const rp = (entry.relativePath ?? "").replaceAll("\\", "/");
|
||||
if (rp.startsWith("rigs/launch/")) return "Stable";
|
||||
return "Experimental";
|
||||
}
|
||||
|
||||
function deriveTypeBadge(entry: SpecLibraryEntry): "APP" | "RIG" | "AGENT" | "WORKFLOW" {
|
||||
function deriveTypeBadge(entry: UnifiedLibraryEntry): "APP" | "RIG" | "AGENT" | "WORKFLOW" | "PACK" {
|
||||
if (entry.kind === "context-pack") return "PACK";
|
||||
if (entry.kind === "workflow") return "WORKFLOW";
|
||||
if (entry.kind === "agent") return "AGENT";
|
||||
if (entry.hasServices) return "APP";
|
||||
if ((entry as SpecLibraryEntry).hasServices) return "APP";
|
||||
return "RIG";
|
||||
}
|
||||
|
||||
function filterEntries(entries: SpecLibraryEntry[], filter: LibraryFilter): SpecLibraryEntry[] {
|
||||
function filterEntries(entries: UnifiedLibraryEntry[], filter: LibraryFilter): UnifiedLibraryEntry[] {
|
||||
switch (filter) {
|
||||
case "apps": return entries.filter((e) => e.kind === "rig" && e.hasServices);
|
||||
case "rigs": return entries.filter((e) => e.kind === "rig" && !e.hasServices);
|
||||
case "apps": return entries.filter((e) => e.kind === "rig" && (e as SpecLibraryEntry).hasServices);
|
||||
case "rigs": return entries.filter((e) => e.kind === "rig" && !(e as SpecLibraryEntry).hasServices);
|
||||
case "agents": return entries.filter((e) => e.kind === "agent");
|
||||
case "workflows": return entries.filter((e) => e.kind === "workflow");
|
||||
case "context-packs": return entries.filter((e) => e.kind === "context-pack");
|
||||
default: return entries;
|
||||
}
|
||||
}
|
||||
|
||||
// PL-014: project ContextPackEntry into the row-shape the LibraryList
|
||||
// renders (adds a `summary` line surfacing files-count + token estimate).
|
||||
function contextPackToUnified(entry: ContextPackEntry): UnifiedLibraryEntry {
|
||||
const firstPurposeLine = entry.purpose?.split("\n")[0]?.trim();
|
||||
const summary = `${entry.files.length} file${entry.files.length === 1 ? "" : "s"} · ~${entry.derivedEstimatedTokens} tokens${firstPurposeLine ? ` · ${firstPurposeLine}` : ""}`;
|
||||
return { ...entry, summary };
|
||||
}
|
||||
|
||||
interface SpecsPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -98,10 +117,10 @@ function LibraryList({
|
||||
renderExpanded,
|
||||
}: {
|
||||
title: string;
|
||||
entries: SpecLibraryEntry[];
|
||||
entries: UnifiedLibraryEntry[];
|
||||
onSelect: (id: string) => void;
|
||||
renderAction?: (entry: SpecLibraryEntry) => ReactNode;
|
||||
renderExpanded?: (entry: SpecLibraryEntry) => ReactNode;
|
||||
renderAction?: (entry: UnifiedLibraryEntry) => ReactNode;
|
||||
renderExpanded?: (entry: UnifiedLibraryEntry) => ReactNode;
|
||||
}) {
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
@@ -245,7 +264,18 @@ export function SpecsPanel({ onClose }: SpecsPanelProps) {
|
||||
await openSurface("/specs/agent");
|
||||
};
|
||||
|
||||
const { data: allLibrary = [] } = useSpecLibrary();
|
||||
const { data: specEntries = [] } = useSpecLibrary();
|
||||
// PL-014: context_packs are a sibling primitive served from a
|
||||
// separate daemon route. Merge them into the unified library list so
|
||||
// filter chips operate on one array.
|
||||
const { data: contextPacks = [] } = useContextPackLibrary();
|
||||
const allLibrary: UnifiedLibraryEntry[] = [
|
||||
...specEntries,
|
||||
// Defensive: if the daemon returns a non-array placeholder (older
|
||||
// version without the route), Array.isArray guard keeps the panel
|
||||
// from blowing up on render.
|
||||
...(Array.isArray(contextPacks) ? contextPacks.map(contextPackToUnified) : []),
|
||||
];
|
||||
const [libraryFilter, setLibraryFilter] = useState<LibraryFilter>("all");
|
||||
const filteredLibrary = filterEntries(allLibrary, libraryFilter);
|
||||
const [addToRigEntryId, setAddToRigEntryId] = useState<string | null>(null);
|
||||
@@ -263,7 +293,7 @@ export function SpecsPanel({ onClose }: SpecsPanelProps) {
|
||||
const rigDraftHistory = recentRigDrafts.filter((draft) => draft.id !== currentRigDraft?.id);
|
||||
const agentDraftHistory = recentAgentDrafts.filter((draft) => draft.id !== currentAgentDraft?.id);
|
||||
|
||||
const startRename = (entry: SpecLibraryEntry) => {
|
||||
const startRename = (entry: UnifiedLibraryEntry) => {
|
||||
setLibraryActionError(null);
|
||||
setConfirmRemoveEntryId(null);
|
||||
setRenameEntryId(entry.id);
|
||||
@@ -295,7 +325,7 @@ export function SpecsPanel({ onClose }: SpecsPanelProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const renderLibraryAction = (entry: SpecLibraryEntry, allowAddToRig: boolean) => (
|
||||
const renderLibraryAction = (entry: UnifiedLibraryEntry, allowAddToRig: boolean) => (
|
||||
<div className="flex items-center gap-1">
|
||||
{allowAddToRig && (
|
||||
<button
|
||||
@@ -339,7 +369,7 @@ export function SpecsPanel({ onClose }: SpecsPanelProps) {
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderLibraryExpanded = (entry: SpecLibraryEntry) => {
|
||||
const renderLibraryExpanded = (entry: UnifiedLibraryEntry) => {
|
||||
const showRename = renameEntryId === entry.id;
|
||||
const showRemove = confirmRemoveEntryId === entry.id;
|
||||
if (!showRename && !showRemove) return null;
|
||||
@@ -465,7 +495,7 @@ export function SpecsPanel({ onClose }: SpecsPanelProps) {
|
||||
<div className="space-y-2">
|
||||
<div className="font-mono text-[8px] uppercase tracking-[0.16em] text-stone-500">Library</div>
|
||||
<div className="flex gap-1">
|
||||
{(["all", "apps", "rigs", "agents", "workflows"] as LibraryFilter[]).map((f) => (
|
||||
{(["all", "apps", "rigs", "agents", "workflows", "context-packs"] as LibraryFilter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
data-testid={`filter-${f}`}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Rig Context / Composable Context Injection v0 (PL-014) — UI hooks
|
||||
// for the context_packs library + review + send.
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export interface ContextPackEntryFile {
|
||||
path: string;
|
||||
role: string;
|
||||
summary: string | null;
|
||||
absolutePath: string | null;
|
||||
bytes: number | null;
|
||||
estimatedTokens: number | null;
|
||||
}
|
||||
|
||||
export interface ContextPackEntry {
|
||||
id: string;
|
||||
kind: "context-pack";
|
||||
name: string;
|
||||
version: string;
|
||||
purpose: string | null;
|
||||
sourceType: "builtin" | "user_file" | "workspace";
|
||||
sourcePath: string;
|
||||
relativePath: string;
|
||||
updatedAt: string;
|
||||
manifestEstimatedTokens: number | null;
|
||||
derivedEstimatedTokens: number;
|
||||
files: ContextPackEntryFile[];
|
||||
}
|
||||
|
||||
export interface ContextPackPreview {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
bundleText: string;
|
||||
bundleBytes: number;
|
||||
estimatedTokens: number;
|
||||
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
|
||||
missingFiles: Array<{ path: string; role: string }>;
|
||||
}
|
||||
|
||||
export interface ContextPackSendResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
destinationSession: string;
|
||||
bundleBytes: number;
|
||||
estimatedTokens: number;
|
||||
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
|
||||
missingFiles: Array<{ path: string; role: string }>;
|
||||
dryRun: boolean;
|
||||
bundleText?: string;
|
||||
sent?: boolean;
|
||||
}
|
||||
|
||||
async function fetchContextPacks(): Promise<ContextPackEntry[]> {
|
||||
const res = await fetch("/api/context-packs/library");
|
||||
if (!res.ok) {
|
||||
if (res.status === 503) return []; // honest fallback when library not configured
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
// Cross-CLI-version drift guard: an older daemon that doesn't ship
|
||||
// the route may surface 200 with a non-array placeholder. Fall back
|
||||
// to an empty list rather than letting consumers .map() into an
|
||||
// exception.
|
||||
return Array.isArray(body) ? body : [];
|
||||
}
|
||||
|
||||
export function useContextPackLibrary() {
|
||||
return useQuery({
|
||||
queryKey: ["context-packs", "library"],
|
||||
queryFn: fetchContextPacks,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchContextPackPreview(id: string): Promise<ContextPackPreview> {
|
||||
const res = await fetch(`/api/context-packs/library/${encodeURIComponent(id)}/preview`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useContextPackPreview(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["context-packs", "preview", id],
|
||||
queryFn: () => fetchContextPackPreview(id!),
|
||||
enabled: !!id,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useContextPackSend() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { id: string; destinationSession: string; dryRun?: boolean }): Promise<ContextPackSendResponse> => {
|
||||
const res = await fetch(`/api/context-packs/library/${encodeURIComponent(input.id)}/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
destinationSession: input.destinationSession,
|
||||
dryRun: input.dryRun ?? false,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["context-packs"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useContextPackSync() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/context-packs/library/sync", { method: "POST" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json() as Promise<{ count: number; entries: ContextPackEntry[] }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["context-packs"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -384,4 +384,135 @@ describe("LibraryReview", () => {
|
||||
expect(screen.getByTestId("agent-drilldown-route")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- PL-014: context-pack review variant ---
|
||||
|
||||
it("renders the context-pack review variant for context-pack:* ids", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/context-packs/library") {
|
||||
return new Response(JSON.stringify([
|
||||
{
|
||||
id: "context-pack:pl-005-priming:1",
|
||||
kind: "context-pack",
|
||||
name: "pl-005-priming",
|
||||
version: "1",
|
||||
purpose: "Priming for PL-005 Phase A review.",
|
||||
sourceType: "user_file",
|
||||
sourcePath: "/home/op/.openrig/context-packs/pl-005-priming",
|
||||
relativePath: "pl-005-priming",
|
||||
updatedAt: "2026-05-04T00:00:00Z",
|
||||
manifestEstimatedTokens: null,
|
||||
derivedEstimatedTokens: 800,
|
||||
files: [
|
||||
{ path: "prd.md", role: "prd", summary: "PRD", absolutePath: "/abs/prd.md", bytes: 200, estimatedTokens: 50 },
|
||||
{ path: "missing.md", role: "proof", summary: null, absolutePath: null, bytes: null, estimatedTokens: null },
|
||||
],
|
||||
},
|
||||
]), { status: 200 });
|
||||
}
|
||||
if (url === "/api/context-packs/library/context-pack%3Apl-005-priming%3A1/preview") {
|
||||
return new Response(JSON.stringify({
|
||||
id: "context-pack:pl-005-priming:1",
|
||||
name: "pl-005-priming",
|
||||
version: "1",
|
||||
bundleText: "# OpenRig Context Pack: pl-005-priming v1\n\nPRD body\n",
|
||||
bundleBytes: 60,
|
||||
estimatedTokens: 15,
|
||||
files: [{ path: "prd.md", role: "prd", bytes: 200, estimatedTokens: 50 }],
|
||||
missingFiles: [{ path: "missing.md", role: "proof" }],
|
||||
}), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(createAppTestRouter({
|
||||
initialPath: "/specs/library/context-pack:pl-005-priming:1",
|
||||
routes: [
|
||||
{ path: "/specs/library/context-pack:pl-005-priming:1", component: () => <LibraryReview entryId="context-pack:pl-005-priming:1" /> },
|
||||
],
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("library-review-context-pack")).toBeDefined());
|
||||
expect(screen.getByText("pl-005-priming")).toBeDefined();
|
||||
expect(screen.getByTestId("lib-pack-version").textContent).toContain("1");
|
||||
expect(screen.getByTestId("lib-pack-files").textContent).toContain("2");
|
||||
// Per-file row with role + size
|
||||
const present = screen.getByTestId("lib-pack-file-prd.md");
|
||||
expect(present.textContent).toContain("role: prd");
|
||||
expect(present.textContent).toContain("200B");
|
||||
// Missing file rendered with MISSING marker + data-missing flag
|
||||
const missing = screen.getByTestId("lib-pack-file-missing.md");
|
||||
expect(missing.getAttribute("data-missing")).toBe("true");
|
||||
expect(missing.textContent).toContain("MISSING");
|
||||
// Bundle preview present + missing-file warning (preview is a
|
||||
// separate query; wait for it to resolve)
|
||||
await waitFor(() => expect(screen.getByTestId("lib-pack-bundle-text")).toBeDefined());
|
||||
expect(screen.getByTestId("lib-pack-bundle-text").textContent).toContain("PRD body");
|
||||
expect(screen.getByTestId("lib-pack-missing-warning")).toBeDefined();
|
||||
});
|
||||
|
||||
it("Send-to-seat modal opens on click and shows running sessions", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/context-packs/library") {
|
||||
return new Response(JSON.stringify([
|
||||
{
|
||||
id: "context-pack:test:1",
|
||||
kind: "context-pack",
|
||||
name: "test",
|
||||
version: "1",
|
||||
purpose: null,
|
||||
sourceType: "user_file",
|
||||
sourcePath: "/x/test",
|
||||
relativePath: "test",
|
||||
updatedAt: "2026-05-04T00:00:00Z",
|
||||
manifestEstimatedTokens: null,
|
||||
derivedEstimatedTokens: 50,
|
||||
files: [{ path: "a.md", role: "r", summary: null, absolutePath: "/abs/a.md", bytes: 30, estimatedTokens: 8 }],
|
||||
},
|
||||
]), { status: 200 });
|
||||
}
|
||||
if (url.endsWith("/preview")) {
|
||||
return new Response(JSON.stringify({
|
||||
id: "context-pack:test:1", name: "test", version: "1",
|
||||
bundleText: "ok", bundleBytes: 2, estimatedTokens: 1,
|
||||
files: [], missingFiles: [],
|
||||
}), { status: 200 });
|
||||
}
|
||||
if (url === "/api/ps") {
|
||||
return new Response(JSON.stringify([
|
||||
{
|
||||
rigId: "rig-1",
|
||||
name: "demo",
|
||||
nodes: [
|
||||
{ canonicalSessionName: "driver@demo", sessionStatus: "running" },
|
||||
{ canonicalSessionName: "qa@demo", sessionStatus: "running" },
|
||||
{ canonicalSessionName: "stopped@demo", sessionStatus: "exited" },
|
||||
],
|
||||
},
|
||||
]), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(createAppTestRouter({
|
||||
initialPath: "/specs/library/context-pack:test:1",
|
||||
routes: [
|
||||
{ path: "/specs/library/context-pack:test:1", component: () => <LibraryReview entryId="context-pack:test:1" /> },
|
||||
],
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("context-pack-send-button")).toBeDefined());
|
||||
fireEvent.click(screen.getByTestId("context-pack-send-button"));
|
||||
await waitFor(() => expect(screen.getByTestId("context-pack-send-modal")).toBeDefined());
|
||||
const select = screen.getByTestId("context-pack-send-session") as HTMLSelectElement;
|
||||
const options = Array.from(select.querySelectorAll("option")).map((o) => o.value);
|
||||
expect(options).toContain("driver@demo");
|
||||
expect(options).toContain("qa@demo");
|
||||
// Stopped session not surfaced (only sessionStatus === "running")
|
||||
expect(options).not.toContain("stopped@demo");
|
||||
});
|
||||
});
|
||||
@@ -385,3 +385,107 @@ describe("SpecsPanel Workflows filter chip (Workflows in Spec Library v0)", () =
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpecsPanel Context Packs filter chip (PL-014)", () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
const mockFetch3 = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = mockFetch3 as unknown as typeof fetch;
|
||||
mockFetch3.mockImplementation(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/api/specs/library") && !url.includes("kind=")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
id: "rig-1",
|
||||
kind: "rig",
|
||||
name: "demo",
|
||||
version: "0.2",
|
||||
sourceType: "builtin",
|
||||
sourcePath: "/specs/rigs/launch/demo/rig.yaml",
|
||||
relativePath: "rigs/launch/demo/rig.yaml",
|
||||
updatedAt: "2026-04-09T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (typeof url === "string" && url.includes("/api/specs/library") && url.includes("kind=")) {
|
||||
return { ok: true, json: async () => [] };
|
||||
}
|
||||
if (typeof url === "string" && url.includes("/api/context-packs/library")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
id: "context-pack:pl-005-priming:1",
|
||||
kind: "context-pack",
|
||||
name: "pl-005-priming",
|
||||
version: "1",
|
||||
purpose: "Priming for PL-005",
|
||||
sourceType: "user_file",
|
||||
sourcePath: "/home/op/.openrig/context-packs/pl-005-priming",
|
||||
relativePath: "pl-005-priming",
|
||||
updatedAt: "2026-05-04T00:00:00Z",
|
||||
manifestEstimatedTokens: null,
|
||||
derivedEstimatedTokens: 800,
|
||||
files: [
|
||||
{ path: "prd.md", role: "prd", summary: null, absolutePath: "/abs/prd.md", bytes: 200, estimatedTokens: 50 },
|
||||
{ path: "proof.md", role: "proof", summary: null, absolutePath: "/abs/proof.md", bytes: 3000, estimatedTokens: 750 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { ok: true, json: async () => [] };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = origFetch;
|
||||
window.localStorage.clear();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function renderFilterPanel() {
|
||||
const onClose = vi.fn();
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
{createAppTestRouter({
|
||||
initialPath: "/",
|
||||
routes: [
|
||||
{ path: "/", component: () => <div>home</div> },
|
||||
{ path: "/specs/library/$entryId", component: () => <div data-testid="library-review">review</div> },
|
||||
],
|
||||
rootComponent: ({ children }) => (
|
||||
<SpecsWorkspaceProvider>
|
||||
<SpecsPanel onClose={onClose} />
|
||||
{children}
|
||||
</SpecsWorkspaceProvider>
|
||||
),
|
||||
})}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
it("renders the Context Packs filter chip and a pack row with PACK badge", async () => {
|
||||
renderFilterPanel();
|
||||
await waitFor(() => expect(screen.getByText("pl-005-priming")).toBeDefined());
|
||||
expect(screen.getByTestId("filter-context-packs")).toBeDefined();
|
||||
const packRow = screen.getByTestId("library-entry-context-pack:pl-005-priming:1");
|
||||
expect(packRow.textContent).toContain("PACK");
|
||||
expect(packRow.textContent).toContain("2 files");
|
||||
expect(packRow.textContent).toContain("~800 tokens");
|
||||
});
|
||||
|
||||
it("clicking Context Packs filter shows only pack entries", async () => {
|
||||
renderFilterPanel();
|
||||
await waitFor(() => expect(screen.getByText("pl-005-priming")).toBeDefined());
|
||||
fireEvent.click(screen.getByTestId("filter-context-packs"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("pl-005-priming")).toBeDefined();
|
||||
expect(screen.queryByText("demo")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user