mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
feat(workspace): typed workspace primitive (PL-007 v0)
PL-007 Workspace Primitive v0 — five typed workspace kinds (user /
project / knowledge / lab / delivery) declared in RigSpec, surfaced
through whoami / node-inventory / queue substrate target_repo, composed
into Files / Progress / Slices / Specs / Steering UI surfaces. ONE
ambitious vertical: paper convention graduates into product schema; all
seven items ship together.
Items shipped:
Item 1 — five typed workspace kinds. WORKSPACE_KINDS enum lives in
domain/types.ts. Reserved set at v0; sixth kind is a v1+ amendment
with a NAMED trigger.
Item 2 — RigSpec.workspace optional block. workspace_root + repos[]
(name/path/kind) + default_repo + knowledge_root. Validates: kinds
in the reserved 5; repo names unique; default_repo references a
declared repo. Codec round-trips. Normalize resolves relative repo
paths against workspace_root. Back-compat: rigs without the block
stay valid.
Item 3 — WhoamiResult.workspace block. whoami-service + workspace-
resolver derive activeRepo (default_repo with OPENRIG_TARGET_REPO env
override) + repos[] + knowledgeRoot/knowledgeKind. Null when no rig
workspace is declared.
Item 4 — queue_items.target_repo column. Migration 039 adds the
typed flag; rigx queue create/handoff/handoff-and-complete/list
accept --target-repo; route validates against source rig's
RigSpec.workspace.repos[] with structured error on unknown name;
handoff inherits when not overridden.
Item 5 — NodeInventoryEntry.workspace block. Per-node activeRepo +
kind derived from cwd via longest-prefix match (knowledge fallback
when cwd is under knowledgeRoot).
Item 6 — rig workspace validate CLI + /api/workspace/validate route.
Walks a workspace root, parses YAML frontmatter, emits structured
gap report (missing-required-field / unrecognized-status-value /
parse-error / missing-frontmatter). Per-kind required fields contract
(knowledge canon: doc/status/created/owner). Advisory only — never
modifies files; non-zero exit on gaps so operators can chain into
hygiene fix loops.
Item 7 — UI integration. WorkspaceKindBadge shared component (5
colors + glyph for compact mode + resolveKindForPath helper). Hook
useWorkspace reads /api/whoami's workspace block. Light-touch
badges in:
A. FilesWorkspace allowlist root selector
B. ProgressWorkspace per-file headers
C. SliceExplorerPanel rows (slicePath surfaced from indexer)
D. SpecsPanel library entries (sourceType-aware)
E. LoopStatePanel + InMotionPanel headers (knowledge canon)
Schema:
Two new migrations (split per-domain so tests can apply only the
half they need):
- 038_workspace_primitive.sql: ALTER TABLE rigs ADD COLUMN
workspace_json TEXT
- 039_queue_target_repo.sql: ALTER TABLE queue_items ADD COLUMN
target_repo TEXT + idx_queue_items_target_repo
No new tables. No new event types. Defensive column probes on
RigRepository (hasRigColumn) + QueueRepository (detectQueueColumn)
mirror the existing hasNodeColumn pattern so older test fixtures
that bypass the canonical migration list degrade gracefully.
Test coverage (45 new daemon tests + 8 new UI tests):
- rigspec-workspace.test.ts (10): validate accepts / rejects
malformed / 5 typed kinds / codec round-trip
- workspace-resolver.test.ts (10): activeRepo resolution + env
override + longest-prefix kind matching + knowledge fallback
- workspace-frontmatter-validator.test.ts (8): per-kind required
fields + status enum + parse-error + advisory invariant
- workspace-routes.test.ts (4): /api/workspace/validate happy paths
+ 400 on missing root / unknown kind
- queue-target-repo.test.ts (7): create round-trip + list filter +
handoff inherit + handoff-and-complete inherit + legacy fixture
degrades gracefully
- whoami-workspace.test.ts (3): workspace populated / null / legacy
fixture
- WorkspaceKindBadge.test.tsx (8): renders 5 kinds + compact glyph
+ resolveKindForPath cases
Daemon 3067/3067 (3022 baseline + 45 new) — tsc clean.
CLI 849/852 (3 pre-existing failures match main baseline) — tsc clean.
UI 567/567 (559 baseline + 8 new) — tsc clean.
v0+1 named triggers (deferred):
- Sixth workspace kind (R&D pod / content-creation): operator
dogfood reports needing one for real work
- Hard-validation frontmatter mode: curate-steward reports advisory-
only insufficient
- rigx wrapper TSV registration for "rigx workspace validate" alias:
operator preference signals matter more than namespace consistency
- workspace-aware Mission Control deep-link composition: post-PL-007
dogfood reveals cross-rig handoff confusion that needs richer
rendering than the field on its own
- Per-task target_repo override on rigx stream emit / chat / etc.:
proven by dogfood that body-only is insufficient
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
a9637fd1f3
commit
f9051590cb
36 files changed
+1961
-72
No files matched your search
@@ -68,6 +68,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--tags <tags>", "Comma-separated tags")
|
||||
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
|
||||
.option("--id <qitemId>", "Idempotent qitem_id (skip if not provided)")
|
||||
.option("--target-repo <name>", "PL-007: typed repo scope (must match a repo in the source rig's RigSpec.workspace.repos[])")
|
||||
.option("--no-nudge", "Suppress the default destination nudge (cold-queue)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
@@ -79,6 +80,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
tags?: string;
|
||||
expiresAt?: string;
|
||||
id?: string;
|
||||
targetRepo?: string;
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
@@ -94,6 +96,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
expiresAt: opts.expiresAt,
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
@@ -178,6 +181,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--target-repo <name>", "PL-007: typed repo scope for the new qitem")
|
||||
.option("--no-nudge", "Suppress the default nudge to the new destination")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
@@ -188,6 +192,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
priority?: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
targetRepo?: string;
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
@@ -204,6 +209,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
@@ -222,6 +228,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--target-repo <name>", "PL-007: typed repo scope for the new qitem")
|
||||
.option("--no-nudge", "Suppress the default nudge to the new destination")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
@@ -232,6 +239,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
priority?: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
targetRepo?: string;
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
@@ -248,6 +256,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
@@ -321,12 +330,14 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--destination <session>", "Filter by destination session")
|
||||
.option("--source <session>", "Filter by source session")
|
||||
.option("--state <state>", "Filter by state (comma-separated for multiple)")
|
||||
.option("--target-repo <name>", "PL-007: filter qitems by target_repo (exact match)")
|
||||
.option("--limit <n>", "Result limit", "100")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
destination?: string;
|
||||
source?: string;
|
||||
state?: string;
|
||||
targetRepo?: string;
|
||||
limit: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
@@ -335,6 +346,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
if (opts.destination) params.set("destinationSession", opts.destination);
|
||||
if (opts.source) params.set("sourceSession", opts.source);
|
||||
if (opts.state) params.set("state", opts.state);
|
||||
if (opts.targetRepo) params.set("targetRepo", opts.targetRepo);
|
||||
if (opts.limit) params.set("limit", opts.limit);
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/list?${params.toString()}`);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// PL-007 Workspace Primitive v0 — `rig workspace` CLI surface.
|
||||
//
|
||||
// v0 surface (one verb): `rig workspace validate`. Walks a workspace root,
|
||||
// parses each .md file's YAML frontmatter, and emits a structured gap
|
||||
// report. Advisory only — never modifies. curate-steward consumes the
|
||||
// report.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import * as path from "node:path";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
export interface WorkspaceDeps extends StatusDeps {}
|
||||
|
||||
interface ValidationGap {
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
kind: string;
|
||||
field: string | null;
|
||||
message: string;
|
||||
workspaceKind: string | null;
|
||||
}
|
||||
|
||||
interface ValidationReport {
|
||||
root: string;
|
||||
workspaceKind: string | null;
|
||||
totalFiles: number;
|
||||
filesWithFrontmatter: number;
|
||||
gapCount: number;
|
||||
gaps: ValidationGap[];
|
||||
}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: WorkspaceDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
const cmd = new Command("workspace").description(
|
||||
"PL-007 Workspace Primitive — typed-kind tooling. v0: `validate` walks a root and reports frontmatter gaps.",
|
||||
);
|
||||
|
||||
const getDeps = (): WorkspaceDeps =>
|
||||
depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("validate [root]")
|
||||
.description(
|
||||
"Walk a workspace root, parse each .md file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: cwd.",
|
||||
)
|
||||
.option("--kind <kind>", "Workspace kind to validate against: user | project | knowledge | lab | delivery")
|
||||
.option("--no-recursive", "Do not descend into subdirectories")
|
||||
.option("--require-frontmatter", "Report a gap for every .md file without a frontmatter delimiter")
|
||||
.option("--max-files <n>", "Hard cap on .md files walked", "10000")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(
|
||||
async (
|
||||
rootArg: string | undefined,
|
||||
opts: {
|
||||
kind?: string;
|
||||
recursive?: boolean;
|
||||
requireFrontmatter?: boolean;
|
||||
maxFiles: string;
|
||||
json?: boolean;
|
||||
},
|
||||
) => {
|
||||
const root = path.resolve(rootArg ?? process.cwd());
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<ValidationReport>("/api/workspace/validate", {
|
||||
root,
|
||||
workspaceKind: opts.kind,
|
||||
recursive: opts.recursive !== false,
|
||||
requireFrontmatter: opts.requireFrontmatter ?? false,
|
||||
maxFiles: Number.parseInt(opts.maxFiles, 10),
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const report = res.data;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(report));
|
||||
} else {
|
||||
renderHumanReport(report);
|
||||
}
|
||||
// Exit non-zero when gaps found — operators chain into hygiene fix loops.
|
||||
if (report.gapCount > 0) process.exitCode = 1;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function renderHumanReport(r: ValidationReport): void {
|
||||
console.log(`workspace root: ${r.root}`);
|
||||
console.log(`workspace kind: ${r.workspaceKind ?? "(none — kind-agnostic structural check)"}`);
|
||||
console.log(`files walked: ${r.totalFiles}`);
|
||||
console.log(`with frontmatter: ${r.filesWithFrontmatter}`);
|
||||
console.log(`gaps: ${r.gapCount}`);
|
||||
if (r.gapCount === 0) {
|
||||
console.log("\n no gaps — canon is clean against v0 contract.");
|
||||
return;
|
||||
}
|
||||
console.log("\n Gaps:");
|
||||
for (const g of r.gaps) {
|
||||
const fieldStr = g.field ? ` [${g.field}]` : "";
|
||||
console.log(` [${g.kind}] ${g.relativePath}${fieldStr}`);
|
||||
console.log(` ${g.message}`);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import { chatroomCommand } from "./commands/chatroom.js";
|
||||
import { specsCommand } from "./commands/specs.js";
|
||||
import { contextPackCommand } from "./commands/context-pack.js";
|
||||
import { agentImageCommand } from "./commands/agent-image.js";
|
||||
import { workspaceCommand, type WorkspaceDeps } from "./commands/workspace.js";
|
||||
import { whoamiCommand } from "./commands/whoami.js";
|
||||
import { unclaimCommand } from "./commands/unclaim.js";
|
||||
import { releaseCommand } from "./commands/release.js";
|
||||
@@ -97,6 +98,7 @@ export interface ProgramDeps {
|
||||
specsDeps?: StatusDeps;
|
||||
contextPackDeps?: StatusDeps;
|
||||
agentImageDeps?: StatusDeps;
|
||||
workspaceDeps?: WorkspaceDeps;
|
||||
whoamiDeps?: StatusDeps;
|
||||
expandDeps?: StatusDeps;
|
||||
envDeps?: StatusDeps;
|
||||
@@ -157,6 +159,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(specsCommand(depsOverride?.specsDeps));
|
||||
program.addCommand(contextPackCommand(depsOverride?.contextPackDeps));
|
||||
program.addCommand(agentImageCommand(depsOverride?.agentImageDeps));
|
||||
program.addCommand(workspaceCommand(depsOverride?.workspaceDeps));
|
||||
program.addCommand(whoamiCommand(depsOverride?.whoamiDeps));
|
||||
program.addCommand(configCommand(depsOverride?.configPath));
|
||||
program.addCommand(preflightCommand());
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* PL-007 Workspace Primitive v0 — rigs.workspace_json column.
|
||||
*
|
||||
* Adds `workspace_json TEXT` to the rigs table. Holds the typed
|
||||
* RigSpec.workspace block as JSON when a rig declares one. Populated by
|
||||
* RigRepository.setRigWorkspace at instantiate time. Whoami /
|
||||
* node-inventory read this column to surface workspace fields alongside
|
||||
* cwd. NULL for rigs without a workspace block.
|
||||
*
|
||||
* The companion queue_items.target_repo column ships in migration 039
|
||||
* so test fixtures that exercise rigs without queue_items (and vice
|
||||
* versa) can apply only the half they need.
|
||||
*/
|
||||
export const workspacePrimitiveSchema: Migration = {
|
||||
name: "038_workspace_primitive.sql",
|
||||
sql: `
|
||||
ALTER TABLE rigs ADD COLUMN workspace_json TEXT;
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* PL-007 Workspace Primitive v0 — queue_items.target_repo column.
|
||||
*
|
||||
* Adds `target_repo TEXT` to the queue_items table. Carries the
|
||||
* per-item typed repo scope when an operator passes
|
||||
* `rigx queue create --target-repo <name>` etc. Validated at the
|
||||
* route layer against the source rig's RigSpec.workspace.repos[].
|
||||
* NULL when the qitem is unambiguous against the rig's default_repo
|
||||
* or when no workspace is declared. Mission Control views surface
|
||||
* the field for cross-rig handoff clarity.
|
||||
*
|
||||
* Companion to migration 038 which adds rigs.workspace_json.
|
||||
*/
|
||||
export const queueTargetRepoSchema: Migration = {
|
||||
name: "039_queue_target_repo.sql",
|
||||
sql: `
|
||||
ALTER TABLE queue_items ADD COLUMN target_repo TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_target_repo ON queue_items(target_repo);
|
||||
`,
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { NodeInventoryEntry, NodeDetailEntry, NodeDetailPeer, NodeDetailEdge, NodeDetailCompactSpec, NodeRestoreOutcome, NodeLifecycleState, Binding, RestoreResult, NodeRecoveryGuidance, Snapshot } from "./types.js";
|
||||
import type { NodeInventoryEntry, NodeDetailEntry, NodeDetailPeer, NodeDetailEdge, NodeDetailCompactSpec, NodeRestoreOutcome, NodeLifecycleState, Binding, RestoreResult, NodeRecoveryGuidance, Snapshot, WorkspaceSpec } from "./types.js";
|
||||
import type { RuntimeAdapter } from "./runtime-adapter.js";
|
||||
import type { ContextUsageStore } from "./context-usage-store.js";
|
||||
import type { AgentActivityStore } from "./agent-activity-store.js";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { probeSessionActivity } from "./session-transport.js";
|
||||
import { findLatestUsableSnapshot } from "./rig-repository.js";
|
||||
import { resolveNodeWorkspace } from "./workspace/workspace-resolver.js";
|
||||
|
||||
// -- Row types for SQL results --
|
||||
|
||||
@@ -289,6 +290,9 @@ export function getNodeInventory(db: Database.Database, rigId: string): NodeInve
|
||||
// Resolve the rig's latest usable snapshot once for the whole projection so per-node
|
||||
// recoverability checks share the same source of truth without N extra queries.
|
||||
const usableSnapshot = findLatestUsableSnapshot(db, rigId);
|
||||
// PL-007: rig's typed workspace block (if declared) loaded once per
|
||||
// projection so per-node kind resolution shares one parse.
|
||||
const workspaceSpec = readRigWorkspaceJson(db, rigId);
|
||||
|
||||
// Join nodes with newest session (max ULID = max session.id string comparison)
|
||||
// and the rig name
|
||||
@@ -387,10 +391,29 @@ export function getNodeInventory(db: Database.Database, rigId: string): NodeInve
|
||||
resumeType: row.resume_type,
|
||||
resumeToken: row.resume_token,
|
||||
startupCompletedAt: row.startup_completed_at,
|
||||
// PL-007 Workspace Primitive — per-node workspace summary derived
|
||||
// from cwd against the rig's typed workspace block. null when the
|
||||
// rig has no workspace declaration.
|
||||
workspace: resolveNodeWorkspace({ spec: workspaceSpec, cwd: row.cwd }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** PL-007 — read the rig's typed workspace block from `rigs.workspace_json`
|
||||
* defensively. Migration 038 may not yet be applied in older test fixtures
|
||||
* that bypass the canonical migration list, so a missing column returns
|
||||
* null cleanly. */
|
||||
function readRigWorkspaceJson(db: Database.Database, rigId: string): WorkspaceSpec | null {
|
||||
try {
|
||||
const row = db.prepare("SELECT workspace_json FROM rigs WHERE id = ?")
|
||||
.get(rigId) as { workspace_json: string | null } | undefined;
|
||||
if (!row || !row.workspace_json) return null;
|
||||
return JSON.parse(row.workspace_json) as WorkspaceSpec;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed node information including startup files, resources, and events.
|
||||
* The adapter dependency is optional — when provided, uses live listInstalled;
|
||||
|
||||
@@ -47,6 +47,11 @@ export interface QueueItem {
|
||||
lastNudgeResult: string | null;
|
||||
lastHeartbeat: string | null;
|
||||
resolution: string | null;
|
||||
/** PL-007 Workspace Primitive — typed repo scope for the qitem. Validated
|
||||
* by the route layer against the source rig's RigSpec.workspace.repos[].
|
||||
* Null when the task is unambiguously the rig's default_repo or
|
||||
* ambiguity is absent. Stored as a dedicated TEXT column (migration 038). */
|
||||
targetRepo: string | null;
|
||||
}
|
||||
|
||||
interface QueueItemRow {
|
||||
@@ -73,6 +78,7 @@ interface QueueItemRow {
|
||||
last_nudge_result: string | null;
|
||||
last_heartbeat: string | null;
|
||||
resolution: string | null;
|
||||
target_repo: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,6 +108,9 @@ export interface QueueCreateInput {
|
||||
tags?: string[];
|
||||
expiresAt?: string;
|
||||
chainOfRecord?: string[];
|
||||
/** PL-007 — typed repo scope for this qitem. Route validates against
|
||||
* source rig's workspace.repos[]; unknown names rejected upstream. */
|
||||
targetRepo?: string | null;
|
||||
/**
|
||||
* R1 fix (PL-004 Phase A revision): Phase A is durable + waking by default.
|
||||
* When true (or omitted), the repository nudges the destination after the
|
||||
@@ -144,6 +153,9 @@ export interface QueueHandoffInput {
|
||||
tags?: string[];
|
||||
/** Default true; nudge the destination after the close+create transaction. */
|
||||
nudge?: boolean;
|
||||
/** PL-007 — typed repo scope for the new qitem. When omitted, the new
|
||||
* qitem inherits the source's targetRepo. */
|
||||
targetRepo?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,6 +176,8 @@ export interface QueueListOptions {
|
||||
destinationSession?: string;
|
||||
sourceSession?: string;
|
||||
state?: QueueState | QueueState[];
|
||||
/** PL-007 — filter qitems by target_repo. Exact match. */
|
||||
targetRepo?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -185,6 +199,18 @@ function newQitemId(): string {
|
||||
return `qitem-${ts}-${hex}`;
|
||||
}
|
||||
|
||||
/** PL-007 — defensive column probe. Older test fixtures bypass the
|
||||
* canonical migration list, so target_repo may be absent. Mirrors
|
||||
* the `hasNodeColumn` pattern in rig-repository.ts. */
|
||||
function detectQueueColumn(db: Database.Database, columnName: string): boolean {
|
||||
try {
|
||||
return db.prepare("PRAGMA table_info(queue_items)").all()
|
||||
.some((row) => (row as { name?: string }).name === columnName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* L3 — Queue repository. Owns CRUD over `queue_items` plus the wired-in
|
||||
* append-only transition log and hot-potato strict-rejection contract.
|
||||
@@ -200,6 +226,12 @@ export class QueueRepository {
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly validateRig: (sessionRef: string) => boolean;
|
||||
private transport: QueueNudgeTransport | undefined;
|
||||
/** PL-007 Workspace Primitive — true when migration 038 has applied the
|
||||
* queue_items.target_repo column. Older test fixtures that bypass the
|
||||
* canonical migration list don't have the column; INSERTs degrade to
|
||||
* the pre-PL-007 statement and target_repo input is silently dropped.
|
||||
* Production daemons always have the column (migration is in startup.ts). */
|
||||
private readonly hasTargetRepoColumn: boolean;
|
||||
|
||||
constructor(
|
||||
db: Database.Database,
|
||||
@@ -222,6 +254,7 @@ export class QueueRepository {
|
||||
this.transitionLog = new QueueTransitionLog(db);
|
||||
this.validateRig = opts?.validateRig ?? (() => true);
|
||||
this.transport = opts?.transport;
|
||||
this.hasTargetRepoColumn = detectQueueColumn(db, "target_repo");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,15 +374,27 @@ export class QueueRepository {
|
||||
const tags = input.tags ? JSON.stringify(input.tags) : null;
|
||||
const chain = input.chainOfRecord ? JSON.stringify(input.chainOfRecord) : null;
|
||||
const expiresAt = input.expiresAt ?? null;
|
||||
const targetRepo = input.targetRepo ?? null;
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO queue_items (
|
||||
qitem_id, ts_created, ts_updated, source_session, destination_session,
|
||||
state, priority, tier, tags, expires_at, chain_of_record, body
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(id, ts, ts, input.sourceSession, input.destinationSession, priority, tier, tags, expiresAt, chain, input.body);
|
||||
if (this.hasTargetRepoColumn) {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO queue_items (
|
||||
qitem_id, ts_created, ts_updated, source_session, destination_session,
|
||||
state, priority, tier, tags, expires_at, chain_of_record, body, target_repo
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(id, ts, ts, input.sourceSession, input.destinationSession, priority, tier, tags, expiresAt, chain, input.body, targetRepo);
|
||||
} else {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO queue_items (
|
||||
qitem_id, ts_created, ts_updated, source_session, destination_session,
|
||||
state, priority, tier, tags, expires_at, chain_of_record, body
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(id, ts, ts, input.sourceSession, input.destinationSession, priority, tier, tags, expiresAt, chain, input.body);
|
||||
}
|
||||
this.transitionLog.append({
|
||||
qitemId: id,
|
||||
state: "pending",
|
||||
@@ -400,6 +445,7 @@ export class QueueRepository {
|
||||
const tier = input.tier ?? source.tier;
|
||||
const tags = input.tags ? JSON.stringify(input.tags) : (source.tags ? JSON.stringify(source.tags) : null);
|
||||
const chain = JSON.stringify([...(source.chainOfRecord ?? []), source.qitemId]);
|
||||
const targetRepo = input.targetRepo === undefined ? source.targetRepo : input.targetRepo;
|
||||
|
||||
const events: Array<{ name: string; payload: import("./types.js").RigEvent }> = [];
|
||||
|
||||
@@ -425,26 +471,25 @@ export class QueueRepository {
|
||||
closureTarget: input.toSession,
|
||||
});
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO queue_items (
|
||||
qitem_id, ts_created, ts_updated, source_session, destination_session,
|
||||
state, priority, tier, tags, handed_off_from, chain_of_record, body
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
newId,
|
||||
ts,
|
||||
ts,
|
||||
input.fromSession,
|
||||
input.toSession,
|
||||
priority,
|
||||
tier,
|
||||
tags,
|
||||
source.qitemId,
|
||||
chain,
|
||||
body
|
||||
);
|
||||
if (this.hasTargetRepoColumn) {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO queue_items (
|
||||
qitem_id, ts_created, ts_updated, source_session, destination_session,
|
||||
state, priority, tier, tags, handed_off_from, chain_of_record, body, target_repo
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(newId, ts, ts, input.fromSession, input.toSession, priority, tier, tags, source.qitemId, chain, body, targetRepo);
|
||||
} else {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO queue_items (
|
||||
qitem_id, ts_created, ts_updated, source_session, destination_session,
|
||||
state, priority, tier, tags, handed_off_from, chain_of_record, body
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(newId, ts, ts, input.fromSession, input.toSession, priority, tier, tags, source.qitemId, chain, body);
|
||||
}
|
||||
|
||||
this.transitionLog.append({
|
||||
qitemId: newId,
|
||||
@@ -521,6 +566,7 @@ export class QueueRepository {
|
||||
const tier = input.tier ?? source.tier;
|
||||
const tags = input.tags ? JSON.stringify(input.tags) : (source.tags ? JSON.stringify(source.tags) : null);
|
||||
const chain = JSON.stringify([...(source.chainOfRecord ?? []), source.qitemId]);
|
||||
const targetRepo = input.targetRepo === undefined ? source.targetRepo : input.targetRepo;
|
||||
|
||||
const events: Array<{ name: string; payload: import("./types.js").RigEvent }> = [];
|
||||
|
||||
@@ -546,26 +592,25 @@ export class QueueRepository {
|
||||
closureTarget: input.toSession,
|
||||
});
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO queue_items (
|
||||
qitem_id, ts_created, ts_updated, source_session, destination_session,
|
||||
state, priority, tier, tags, handed_off_from, chain_of_record, body
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
newId,
|
||||
ts,
|
||||
ts,
|
||||
input.fromSession,
|
||||
input.toSession,
|
||||
priority,
|
||||
tier,
|
||||
tags,
|
||||
source.qitemId,
|
||||
chain,
|
||||
body
|
||||
);
|
||||
if (this.hasTargetRepoColumn) {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO queue_items (
|
||||
qitem_id, ts_created, ts_updated, source_session, destination_session,
|
||||
state, priority, tier, tags, handed_off_from, chain_of_record, body, target_repo
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(newId, ts, ts, input.fromSession, input.toSession, priority, tier, tags, source.qitemId, chain, body, targetRepo);
|
||||
} else {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO queue_items (
|
||||
qitem_id, ts_created, ts_updated, source_session, destination_session,
|
||||
state, priority, tier, tags, handed_off_from, chain_of_record, body
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(newId, ts, ts, input.fromSession, input.toSession, priority, tier, tags, source.qitemId, chain, body);
|
||||
}
|
||||
|
||||
this.transitionLog.append({
|
||||
qitemId: newId,
|
||||
@@ -909,6 +954,10 @@ export class QueueRepository {
|
||||
conditions.push(`state IN (${placeholders})`);
|
||||
params.push(...states);
|
||||
}
|
||||
if (opts?.targetRepo && this.hasTargetRepoColumn) {
|
||||
conditions.push("target_repo = ?");
|
||||
params.push(opts.targetRepo);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
params.push(limit);
|
||||
@@ -1037,6 +1086,9 @@ export class QueueRepository {
|
||||
lastNudgeResult: row.last_nudge_result,
|
||||
lastHeartbeat: row.last_heartbeat,
|
||||
resolution: row.resolution,
|
||||
// PL-007: target_repo present only when migration 038 has applied;
|
||||
// older test fixtures supply legacy rows where target_repo is undefined.
|
||||
targetRepo: row.target_repo ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,45 @@ export class RigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PL-007 Workspace Primitive — persist the typed workspace block on the
|
||||
* rigs row. Stored as JSON in `workspace_json` (migration 038). Pass
|
||||
* null to clear. Older test fixtures that bypass the canonical migration
|
||||
* list don't have the column; setter is a no-op in that case (the
|
||||
* caller's contract is "best-effort persistence"). Whoami / node-inventory
|
||||
* read this column to surface the rig's workspace block alongside cwd.
|
||||
*/
|
||||
setRigWorkspace(rigId: string, workspace: import("./types.js").WorkspaceSpec | null): void {
|
||||
if (!this.hasRigColumn("workspace_json")) return;
|
||||
const json = workspace ? JSON.stringify(workspace) : null;
|
||||
this.db.prepare("UPDATE rigs SET workspace_json = ?, updated_at = ? WHERE id = ?")
|
||||
.run(json, new Date().toISOString(), rigId);
|
||||
}
|
||||
|
||||
/** PL-007 — read the persisted workspace block for a rig. */
|
||||
getRigWorkspace(rigId: string): import("./types.js").WorkspaceSpec | null {
|
||||
if (!this.hasRigColumn("workspace_json")) return null;
|
||||
const row = this.db.prepare("SELECT workspace_json FROM rigs WHERE id = ?")
|
||||
.get(rigId) as { workspace_json: string | null } | undefined;
|
||||
if (!row || !row.workspace_json) return null;
|
||||
try {
|
||||
return JSON.parse(row.workspace_json) as import("./types.js").WorkspaceSpec;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** PL-007 — defensive column probe on rigs (migration 038's
|
||||
* workspace_json is absent in legacy test fixtures). */
|
||||
private hasRigColumn(columnName: string): boolean {
|
||||
try {
|
||||
return this.db.prepare("PRAGMA table_info(rigs)").all()
|
||||
.some((row) => (row as { name?: string }).name === columnName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
addNode(rigId: string, logicalId: string, opts?: NodeOptions): Node {
|
||||
// Same-rig guard for podId
|
||||
if (opts?.podId) {
|
||||
|
||||
@@ -18,6 +18,19 @@ export class RigSpecCodec {
|
||||
if (spec.cultureFile) doc["culture_file"] = spec.cultureFile;
|
||||
if (spec.docs && spec.docs.length > 0) doc["docs"] = spec.docs.map((d) => ({ path: d.path }));
|
||||
if (spec.startup) doc["startup"] = serializeStartupBlock(spec.startup);
|
||||
// PL-007: optional rig-level workspace block. Repos round-trip with
|
||||
// their normalized absolute path; the codec does not strip back to
|
||||
// workspace-relative since it has no signal that the original author
|
||||
// wrote a relative path.
|
||||
if (spec.workspace) {
|
||||
const ws: Record<string, unknown> = {
|
||||
workspace_root: spec.workspace.workspaceRoot,
|
||||
repos: spec.workspace.repos.map((r) => ({ name: r.name, path: r.path, kind: r.kind })),
|
||||
};
|
||||
if (spec.workspace.defaultRepo !== undefined) ws["default_repo"] = spec.workspace.defaultRepo;
|
||||
if (spec.workspace.knowledgeRoot !== undefined) ws["knowledge_root"] = spec.workspace.knowledgeRoot;
|
||||
doc["workspace"] = ws;
|
||||
}
|
||||
|
||||
doc["pods"] = spec.pods.map((pod) => {
|
||||
const p: Record<string, unknown> = {
|
||||
|
||||
@@ -410,6 +410,11 @@ export class PodRigInstantiator {
|
||||
persistedEvents.push(this.deps.eventBus.persistWithinTransaction({ type: "rig.created", rigId: materializedRigId }));
|
||||
}
|
||||
|
||||
// PL-007: persist the rig's typed workspace block when declared.
|
||||
if (rigSpec.workspace) {
|
||||
this.deps.rigRepo.setRigWorkspace(materializedRigId, rigSpec.workspace);
|
||||
}
|
||||
|
||||
const currentRig = this.deps.rigRepo.getRig(materializedRigId)!;
|
||||
const logicalIdToNodeId = new Map(currentRig.nodes.map((node) => [node.logicalId, node.id]));
|
||||
const existingPodIds = new Set(
|
||||
@@ -643,6 +648,11 @@ export class PodRigInstantiator {
|
||||
try {
|
||||
const rig = this.deps.rigRepo.createRig(rigSpec.name);
|
||||
rigId = rig.id;
|
||||
// PL-007: persist typed workspace block (when declared) on the rig
|
||||
// record. Whoami / node-inventory read it via getRigWorkspace().
|
||||
if (rigSpec.workspace) {
|
||||
this.deps.rigRepo.setRigWorkspace(rigId, rigSpec.workspace);
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, code: "instantiate_error", message: (err as Error).message };
|
||||
}
|
||||
|
||||
@@ -9,10 +9,14 @@ import type {
|
||||
RigServicesSurface,
|
||||
RigServicesCheckpointHook,
|
||||
ValidationResult,
|
||||
WorkspaceSpec,
|
||||
WorkspaceRepoSpec,
|
||||
} from "./types.js";
|
||||
import { WORKSPACE_KINDS } from "./types.js";
|
||||
import { validateSafePath } from "./path-safety.js";
|
||||
import { validateStartupBlock, normalizeStartupBlock } from "./startup-validation.js";
|
||||
import { COMPOSE_PROJECT_NAME_PATTERN, deriveComposeProjectName } from "./compose-project-name.js";
|
||||
import * as path from "node:path";
|
||||
|
||||
// -- Canonical pod-aware RigSpec validation (AgentSpec reboot) --
|
||||
|
||||
@@ -23,6 +27,7 @@ const VALID_IMPORT_PREFIXES = ["local:", "path:"];
|
||||
const VALID_SERVICES_KIND = new Set(["compose"]);
|
||||
const VALID_DOWN_POLICIES = new Set(["leave_running", "down", "down_and_volumes"]);
|
||||
const VALID_WAIT_TARGET_CONDITIONS = new Set(["healthy"]);
|
||||
const VALID_WORKSPACE_KINDS = new Set<string>(WORKSPACE_KINDS as readonly string[]);
|
||||
|
||||
/**
|
||||
* Pod-aware RigSpec validator. Canonical contract for the AgentSpec reboot.
|
||||
@@ -88,6 +93,13 @@ export class RigSpecSchema {
|
||||
errors.push(...validateServicesBlock(obj["services"], "services"));
|
||||
}
|
||||
|
||||
// PL-007: optional workspace block (typed primitive). Rigs without it
|
||||
// stay valid for back-compat; whoami/node-inventory return null
|
||||
// workspace when not declared.
|
||||
if (obj["workspace"] !== undefined && obj["workspace"] !== null) {
|
||||
errors.push(...validateWorkspaceBlock(obj["workspace"], "workspace"));
|
||||
}
|
||||
|
||||
// pods: required array
|
||||
if (!obj["pods"] || !Array.isArray(obj["pods"])) {
|
||||
errors.push("pods: required non-empty array");
|
||||
@@ -155,12 +167,111 @@ export class RigSpecSchema {
|
||||
docs,
|
||||
startup: raw["startup"] ? normalizeStartupBlock(raw["startup"]) : undefined,
|
||||
services: raw["services"] ? normalizeServicesBlock(raw["services"], raw["name"] as string) : undefined,
|
||||
workspace: raw["workspace"] ? normalizeWorkspaceBlock(raw["workspace"]) : undefined,
|
||||
pods,
|
||||
edges,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** PL-007 Workspace Primitive — validate the optional rig-level workspace
|
||||
* block. Required: workspace_root (non-empty string), repos (array of
|
||||
* {name, path, kind}). Optional: default_repo, knowledge_root.
|
||||
* Constraints: kinds restricted to the reserved 5; repo names unique;
|
||||
* default_repo must reference a declared repo; paths must be non-empty
|
||||
* strings (existence is NOT enforced at validate time per PL-007 PRD —
|
||||
* schema is paper-only; live filesystem checks happen at preflight). */
|
||||
function validateWorkspaceBlock(raw: unknown, prefix: string): string[] {
|
||||
if (typeof raw !== "object" || Array.isArray(raw)) return [`${prefix}: must be an object`];
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const errors: string[] = [];
|
||||
|
||||
const wr = obj["workspace_root"];
|
||||
if (typeof wr !== "string" || wr.trim() === "") {
|
||||
errors.push(`${prefix}.workspace_root: required non-empty string`);
|
||||
}
|
||||
|
||||
const rawRepos = obj["repos"];
|
||||
const repoNames = new Set<string>();
|
||||
if (!Array.isArray(rawRepos)) {
|
||||
errors.push(`${prefix}.repos: required array`);
|
||||
} else {
|
||||
for (let i = 0; i < rawRepos.length; i++) {
|
||||
const repo = rawRepos[i];
|
||||
const repoPrefix = `${prefix}.repos[${i}]`;
|
||||
if (typeof repo !== "object" || repo === null || Array.isArray(repo)) {
|
||||
errors.push(`${repoPrefix}: must be an object with name, path, kind`);
|
||||
continue;
|
||||
}
|
||||
const r = repo as Record<string, unknown>;
|
||||
const name = r["name"];
|
||||
if (typeof name !== "string" || name.trim() === "") {
|
||||
errors.push(`${repoPrefix}.name: required non-empty string`);
|
||||
} else if (repoNames.has(name)) {
|
||||
errors.push(`${repoPrefix}.name: duplicate repo name "${name}"`);
|
||||
} else {
|
||||
repoNames.add(name);
|
||||
}
|
||||
const repoPath = r["path"];
|
||||
if (typeof repoPath !== "string" || repoPath.trim() === "") {
|
||||
errors.push(`${repoPrefix}.path: required non-empty string`);
|
||||
}
|
||||
const kind = r["kind"];
|
||||
if (typeof kind !== "string" || !VALID_WORKSPACE_KINDS.has(kind)) {
|
||||
errors.push(`${repoPrefix}.kind: must be one of ${[...VALID_WORKSPACE_KINDS].join(", ")} (got ${JSON.stringify(kind)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultRepo = obj["default_repo"];
|
||||
if (defaultRepo !== undefined && defaultRepo !== null) {
|
||||
if (typeof defaultRepo !== "string" || defaultRepo.trim() === "") {
|
||||
errors.push(`${prefix}.default_repo: must be a non-empty string when present`);
|
||||
} else if (repoNames.size > 0 && !repoNames.has(defaultRepo)) {
|
||||
errors.push(`${prefix}.default_repo: "${defaultRepo}" does not match any repo in repos[]`);
|
||||
}
|
||||
}
|
||||
|
||||
const knowledgeRoot = obj["knowledge_root"];
|
||||
if (knowledgeRoot !== undefined && knowledgeRoot !== null) {
|
||||
if (typeof knowledgeRoot !== "string" || knowledgeRoot.trim() === "") {
|
||||
errors.push(`${prefix}.knowledge_root: must be a non-empty string when present`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** PL-007 — normalize the YAML workspace block into typed WorkspaceSpec.
|
||||
* Resolves relative repo paths against workspace_root (per the convention
|
||||
* worked example where authors write `path: openrig` relative to the hub). */
|
||||
function normalizeWorkspaceBlock(raw: unknown): WorkspaceSpec | undefined {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const workspaceRoot = obj["workspace_root"] as string;
|
||||
const rawRepos = obj["repos"];
|
||||
const repos: WorkspaceRepoSpec[] = [];
|
||||
if (Array.isArray(rawRepos)) {
|
||||
for (const r of rawRepos as Record<string, unknown>[]) {
|
||||
const declaredPath = r["path"] as string;
|
||||
const absolute = path.isAbsolute(declaredPath)
|
||||
? declaredPath
|
||||
: path.resolve(workspaceRoot, declaredPath);
|
||||
repos.push({
|
||||
name: r["name"] as string,
|
||||
path: absolute,
|
||||
kind: r["kind"] as WorkspaceRepoSpec["kind"],
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
workspaceRoot,
|
||||
repos,
|
||||
defaultRepo: typeof obj["default_repo"] === "string" ? (obj["default_repo"] as string) : undefined,
|
||||
knowledgeRoot: typeof obj["knowledge_root"] === "string" ? (obj["knowledge_root"] as string) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Pod validation --
|
||||
|
||||
function validatePod(pod: Record<string, unknown>, index: number, podIds: Set<string>): string[] {
|
||||
|
||||
@@ -77,6 +77,10 @@ export interface SliceListEntry {
|
||||
qitemCount: number;
|
||||
hasProofPacket: boolean;
|
||||
lastActivityAt: string | null;
|
||||
/** PL-007 — absolute filesystem path of the slice folder. UI resolves
|
||||
* workspace kind by matching this against RigSpec.workspace.repos[].path
|
||||
* / knowledgeRoot. Always populated by toListEntry. */
|
||||
slicePath: string;
|
||||
}
|
||||
|
||||
export interface SliceIndexerOpts {
|
||||
@@ -219,6 +223,7 @@ export class SliceIndexer {
|
||||
qitemCount: qitemIds.length,
|
||||
hasProofPacket: proofPacket !== null,
|
||||
lastActivityAt,
|
||||
slicePath,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -410,6 +410,19 @@ export interface NodeInventoryEntry {
|
||||
startupCompletedAt: string | null;
|
||||
agentActivity?: AgentActivity;
|
||||
contextUsage?: ContextUsage;
|
||||
/** PL-007: per-node workspace block when the rig declares a workspace.
|
||||
* workspaceRoot mirrors RigSpec.workspace.workspaceRoot. activeRepo is
|
||||
* the repo whose path contains the node's cwd, or RigSpec.workspace.
|
||||
* defaultRepo when no containing repo is found. kind is the kind of
|
||||
* the active repo, or `knowledge` when cwd is under knowledgeRoot.
|
||||
* null when the rig does not declare a workspace. */
|
||||
workspace?: NodeWorkspaceInfo | null;
|
||||
}
|
||||
|
||||
export interface NodeWorkspaceInfo {
|
||||
workspaceRoot: string;
|
||||
activeRepo: string | null;
|
||||
kind: WorkspaceKind | null;
|
||||
}
|
||||
|
||||
export interface NodeDetailPeer {
|
||||
@@ -705,6 +718,36 @@ export interface RigSpecDoc {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PL-007 Workspace Primitive — typed workspace kinds enum. Reserved set
|
||||
* at v0; adding a sixth kind is a v1+ amendment per the PL-007 product
|
||||
* spec. Each kind has folder shape + frontmatter contract + ownership
|
||||
* rules (see `frontmatter-validator.ts` for the per-kind required-field
|
||||
* map).
|
||||
*/
|
||||
export const WORKSPACE_KINDS = ["user", "project", "knowledge", "lab", "delivery"] as const;
|
||||
export type WorkspaceKind = (typeof WORKSPACE_KINDS)[number];
|
||||
|
||||
/** PL-007 — RigSpec.workspace.repos[] entry (typed). */
|
||||
export interface WorkspaceRepoSpec {
|
||||
name: string;
|
||||
/** Absolute path after normalization. Authors may declare a path relative to
|
||||
* `workspaceRoot` in YAML; the codec resolves to absolute at parse time. */
|
||||
path: string;
|
||||
kind: WorkspaceKind;
|
||||
}
|
||||
|
||||
/** PL-007 — Optional RigSpec.workspace block. Rigs without it stay valid;
|
||||
* whoami / node-inventory return a null workspace block in that case. */
|
||||
export interface WorkspaceSpec {
|
||||
workspaceRoot: string;
|
||||
repos: WorkspaceRepoSpec[];
|
||||
defaultRepo?: string;
|
||||
/** Optional knowledge-canon root (e.g., substrate/shared-docs/openrig-work).
|
||||
* Treated as kind=knowledge when surfaced through whoami / UI. */
|
||||
knowledgeRoot?: string;
|
||||
}
|
||||
|
||||
export interface RigSpec {
|
||||
version: string;
|
||||
name: string;
|
||||
@@ -713,6 +756,8 @@ export interface RigSpec {
|
||||
docs?: RigSpecDoc[];
|
||||
startup?: StartupBlock;
|
||||
services?: RigServicesSpec;
|
||||
/** PL-007 Workspace Primitive — optional typed workspace declaration. */
|
||||
workspace?: WorkspaceSpec;
|
||||
pods: RigSpecPod[];
|
||||
edges: RigSpecCrossPodEdge[];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type Database from "better-sqlite3";
|
||||
import type { RigRepository } from "./rig-repository.js";
|
||||
import type { SessionRegistry } from "./session-registry.js";
|
||||
import type { TranscriptStore } from "./transcript-store.js";
|
||||
import { resolveWorkspaceContext, type WhoamiWorkspaceBlock } from "./workspace/workspace-resolver.js";
|
||||
|
||||
export interface WhoamiResult {
|
||||
resolvedBy: "node_id" | "session_name";
|
||||
@@ -52,6 +53,10 @@ export interface WhoamiResult {
|
||||
* Codex: threadId from the per-pid logs DB. Claude Code: resumeToken
|
||||
* + current usage from the context-usage sample. Terminal: null. */
|
||||
runtimeContext?: RuntimeContext | null;
|
||||
/** PL-007 Workspace Primitive v0 — typed workspace block when the
|
||||
* rig's RigSpec declares one. null when the rig has no workspace
|
||||
* declaration; agents fall back to cwd-only orientation. */
|
||||
workspace?: WhoamiWorkspaceBlock | null;
|
||||
}
|
||||
|
||||
export type RuntimeContext =
|
||||
@@ -308,6 +313,16 @@ export class WhoamiService {
|
||||
// conversation context and report null.
|
||||
const runtimeContext = this.computeRuntimeContext(nodeRow.id, identity.runtime, contextUsage);
|
||||
|
||||
// PL-007: workspace block resolved from RigSpec.workspace. activeRepo
|
||||
// resolves per-rig default first; envOverride lets per-session
|
||||
// OPENRIG_TARGET_REPO override (subject to repo-name validation).
|
||||
const workspaceSpec = this.rigRepo.getRigWorkspace(nodeRow.rig_id);
|
||||
const workspace = resolveWorkspaceContext({
|
||||
spec: workspaceSpec,
|
||||
cwd: nodeRow.cwd,
|
||||
envOverride: process.env["OPENRIG_TARGET_REPO"] ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
resolvedBy,
|
||||
identity,
|
||||
@@ -317,6 +332,7 @@ export class WhoamiService {
|
||||
commands: { sendExamples, captureExamples },
|
||||
contextUsage,
|
||||
runtimeContext,
|
||||
workspace,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// PL-007 Workspace Primitive v0 — frontmatter validator (advisory).
|
||||
//
|
||||
// Walks a workspace root, parses each .md file's YAML frontmatter
|
||||
// (delimited by `---` lines at the start of file), and validates per-kind
|
||||
// required fields. Outputs a structured gap report:
|
||||
//
|
||||
// - missing-required-field
|
||||
// - unrecognized-status-value
|
||||
// - parse-error (frontmatter present but malformed)
|
||||
// - missing-frontmatter (no frontmatter delimiter found)
|
||||
//
|
||||
// Advisory only — never modifies files. curate-steward consumes the
|
||||
// gap report as hygiene input. The validator is deliberately minimal at
|
||||
// v0 (per PL-007 PRD); broken-cross-reference / non-conforming-structure
|
||||
// rules are deferred to v1+.
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import type { WorkspaceKind } from "../types.js";
|
||||
import { WORKSPACE_KINDS } from "../types.js";
|
||||
|
||||
export type FrontmatterGapKind =
|
||||
| "missing-required-field"
|
||||
| "unrecognized-status-value"
|
||||
| "parse-error"
|
||||
| "missing-frontmatter";
|
||||
|
||||
export interface FrontmatterGap {
|
||||
filePath: string;
|
||||
/** Path relative to the validation root, for stable cross-machine output. */
|
||||
relativePath: string;
|
||||
kind: FrontmatterGapKind;
|
||||
/** Field name when kind === "missing-required-field" or
|
||||
* "unrecognized-status-value"; null otherwise. */
|
||||
field: string | null;
|
||||
message: string;
|
||||
/** Workspace kind the file was validated against (e.g. "knowledge"). */
|
||||
workspaceKind: WorkspaceKind | null;
|
||||
}
|
||||
|
||||
export interface FrontmatterValidationReport {
|
||||
root: string;
|
||||
/** Workspace kind applied to all files under this root. v0: caller picks
|
||||
* one root + one kind per invocation. */
|
||||
workspaceKind: WorkspaceKind | null;
|
||||
totalFiles: number;
|
||||
filesWithFrontmatter: number;
|
||||
gapCount: number;
|
||||
gaps: FrontmatterGap[];
|
||||
}
|
||||
|
||||
const VALID_STATUS_VALUES = new Set(["active", "draft", "archived", "superseded"]);
|
||||
|
||||
/** Per-kind required frontmatter fields. v0 minimum baseline (advisory). */
|
||||
const REQUIRED_FIELDS_BY_KIND: Record<WorkspaceKind, readonly string[]> = {
|
||||
user: ["doc"],
|
||||
project: ["doc"],
|
||||
knowledge: ["doc", "status", "created", "owner"],
|
||||
lab: ["doc", "status", "created", "owner"],
|
||||
delivery: ["doc", "status", "created", "owner"],
|
||||
};
|
||||
|
||||
export interface ValidateOpts {
|
||||
root: string;
|
||||
workspaceKind?: WorkspaceKind;
|
||||
/** When true, recurse into subdirectories. Defaults to true. */
|
||||
recursive?: boolean;
|
||||
/** When true, validate every .md file even if frontmatter is absent
|
||||
* (records a `missing-frontmatter` gap). Defaults to false — files
|
||||
* without `---` are skipped silently (treated as informal notes). */
|
||||
requireFrontmatter?: boolean;
|
||||
/** Hard cap on files walked. Defaults to 10000. Prevents accidental
|
||||
* multi-GB walks from runaway invocations. */
|
||||
maxFiles?: number;
|
||||
}
|
||||
|
||||
/** Run the frontmatter validator. */
|
||||
export function validateWorkspaceFrontmatter(opts: ValidateOpts): FrontmatterValidationReport {
|
||||
const root = path.resolve(opts.root);
|
||||
const recursive = opts.recursive ?? true;
|
||||
const requireFrontmatter = opts.requireFrontmatter ?? false;
|
||||
const maxFiles = opts.maxFiles ?? 10000;
|
||||
const kind = isValidKind(opts.workspaceKind) ? opts.workspaceKind! : null;
|
||||
|
||||
const gaps: FrontmatterGap[] = [];
|
||||
let totalFiles = 0;
|
||||
let filesWithFrontmatter = 0;
|
||||
|
||||
const walk = (dir: string): void => {
|
||||
if (totalFiles >= maxFiles) return;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (totalFiles >= maxFiles) return;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!recursive) continue;
|
||||
// Skip noise dirs that aren't canon: node_modules, .git, .worktrees,
|
||||
// dist/build artifacts. Authors writing canon don't put these here.
|
||||
if (
|
||||
entry.name === "node_modules" ||
|
||||
entry.name === ".git" ||
|
||||
entry.name === ".worktrees" ||
|
||||
entry.name === "dist" ||
|
||||
entry.name === "build"
|
||||
) continue;
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
||||
totalFiles++;
|
||||
const result = validateFile(full, root, kind, requireFrontmatter);
|
||||
if (result.hasFrontmatter) filesWithFrontmatter++;
|
||||
gaps.push(...result.gaps);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
|
||||
return {
|
||||
root,
|
||||
workspaceKind: kind,
|
||||
totalFiles,
|
||||
filesWithFrontmatter,
|
||||
gapCount: gaps.length,
|
||||
gaps,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidKind(k: WorkspaceKind | undefined): k is WorkspaceKind {
|
||||
return typeof k === "string" && (WORKSPACE_KINDS as readonly string[]).includes(k);
|
||||
}
|
||||
|
||||
interface FileValidation {
|
||||
hasFrontmatter: boolean;
|
||||
gaps: FrontmatterGap[];
|
||||
}
|
||||
|
||||
function validateFile(
|
||||
filePath: string,
|
||||
root: string,
|
||||
kind: WorkspaceKind | null,
|
||||
requireFrontmatter: boolean,
|
||||
): FileValidation {
|
||||
const relativePath = path.relative(root, filePath);
|
||||
const out: FileValidation = { hasFrontmatter: false, gaps: [] };
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(filePath, "utf-8");
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
|
||||
const fm = extractFrontmatter(raw);
|
||||
if (!fm.found) {
|
||||
if (requireFrontmatter) {
|
||||
out.gaps.push({
|
||||
filePath,
|
||||
relativePath,
|
||||
kind: "missing-frontmatter",
|
||||
field: null,
|
||||
message: "no frontmatter delimiter (---) found at start of file",
|
||||
workspaceKind: kind,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
out.hasFrontmatter = true;
|
||||
|
||||
let parsed: Record<string, unknown> | null;
|
||||
try {
|
||||
parsed = parseYaml(fm.body) as Record<string, unknown> | null;
|
||||
} catch (err) {
|
||||
out.gaps.push({
|
||||
filePath,
|
||||
relativePath,
|
||||
kind: "parse-error",
|
||||
field: null,
|
||||
message: err instanceof Error ? err.message : "YAML parse error",
|
||||
workspaceKind: kind,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
out.gaps.push({
|
||||
filePath,
|
||||
relativePath,
|
||||
kind: "parse-error",
|
||||
field: null,
|
||||
message: "frontmatter is not a YAML object",
|
||||
workspaceKind: kind,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
if (kind) {
|
||||
const required = REQUIRED_FIELDS_BY_KIND[kind];
|
||||
for (const field of required) {
|
||||
if (!(field in parsed) || parsed[field] === null || parsed[field] === undefined || parsed[field] === "") {
|
||||
out.gaps.push({
|
||||
filePath,
|
||||
relativePath,
|
||||
kind: "missing-required-field",
|
||||
field,
|
||||
message: `${kind} canon requires "${field}" frontmatter field`,
|
||||
workspaceKind: kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
// status enum check (only when status is present + a string)
|
||||
if (typeof parsed["status"] === "string" && !VALID_STATUS_VALUES.has(parsed["status"] as string)) {
|
||||
out.gaps.push({
|
||||
filePath,
|
||||
relativePath,
|
||||
kind: "unrecognized-status-value",
|
||||
field: "status",
|
||||
message: `status "${parsed["status"]}" is not one of: ${[...VALID_STATUS_VALUES].join(", ")}`,
|
||||
workspaceKind: kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface FrontmatterExtraction {
|
||||
found: boolean;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Extract YAML frontmatter delimited by `---` lines. The opening `---`
|
||||
* must be on the first line. Returns the frontmatter body (between the
|
||||
* delimiters) when found. */
|
||||
function extractFrontmatter(raw: string): FrontmatterExtraction {
|
||||
if (!raw.startsWith("---")) return { found: false, body: "" };
|
||||
// Find the closing delimiter, allowing both LF and CRLF line endings.
|
||||
const lines = raw.split(/\r?\n/);
|
||||
if (lines[0]!.trim() !== "---") return { found: false, body: "" };
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i]!.trim() === "---") {
|
||||
return { found: true, body: lines.slice(1, i).join("\n") };
|
||||
}
|
||||
}
|
||||
return { found: false, body: "" };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// PL-007 Workspace Primitive v0 — runtime resolution of typed workspace
|
||||
// context for whoami / node-inventory consumers.
|
||||
//
|
||||
// Given a persisted RigSpec.workspace block + a node's cwd + an optional
|
||||
// per-session env override, derive:
|
||||
// - workspaceRoot — verbatim from the spec
|
||||
// - activeRepo — env override, else default_repo, else null
|
||||
// - repos — typed list (name, path, kind)
|
||||
// - knowledgeRoot, knowledgeKind — verbatim when declared
|
||||
//
|
||||
// Per-node kind resolution (used by node-inventory) walks the cwd up the
|
||||
// directory tree looking for the longest containing repo path; falls back
|
||||
// to "knowledge" when cwd is under knowledgeRoot, else null.
|
||||
|
||||
import * as path from "node:path";
|
||||
import type { WorkspaceSpec, WorkspaceKind, NodeWorkspaceInfo } from "../types.js";
|
||||
|
||||
export interface WhoamiWorkspaceBlock {
|
||||
workspaceRoot: string;
|
||||
activeRepo: string | null;
|
||||
repos: Array<{ name: string; path: string; kind: WorkspaceKind }>;
|
||||
knowledgeRoot: string | null;
|
||||
knowledgeKind: WorkspaceKind | null;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceContext(opts: {
|
||||
spec: WorkspaceSpec | null;
|
||||
cwd: string | null;
|
||||
envOverride: string | null;
|
||||
}): WhoamiWorkspaceBlock | null {
|
||||
const { spec, envOverride } = opts;
|
||||
if (!spec) return null;
|
||||
|
||||
// env override wins when it names a declared repo; otherwise fall through
|
||||
// to default_repo. Unknown override is honored verbatim per PL-007 PRD §
|
||||
// Item 3 — operators set OPENRIG_TARGET_REPO consciously.
|
||||
const repoNames = new Set(spec.repos.map((r) => r.name));
|
||||
let activeRepo: string | null = null;
|
||||
if (envOverride && envOverride.trim() !== "") {
|
||||
activeRepo = envOverride;
|
||||
} else if (spec.defaultRepo && repoNames.has(spec.defaultRepo)) {
|
||||
activeRepo = spec.defaultRepo;
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceRoot: spec.workspaceRoot,
|
||||
activeRepo,
|
||||
repos: spec.repos.map((r) => ({ name: r.name, path: r.path, kind: r.kind })),
|
||||
knowledgeRoot: spec.knowledgeRoot ?? null,
|
||||
knowledgeKind: spec.knowledgeRoot ? "knowledge" : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** PL-007 — per-node workspace summary derived from a node's cwd against
|
||||
* the rig's WorkspaceSpec. Used by NodeInventory. Returns null when the
|
||||
* rig has no workspace declaration. */
|
||||
export function resolveNodeWorkspace(opts: {
|
||||
spec: WorkspaceSpec | null;
|
||||
cwd: string | null;
|
||||
}): NodeWorkspaceInfo | null {
|
||||
const { spec, cwd } = opts;
|
||||
if (!spec) return null;
|
||||
|
||||
let activeRepo: string | null = null;
|
||||
let kind: WorkspaceKind | null = null;
|
||||
if (cwd) {
|
||||
// Find the longest-prefix repo whose path contains cwd.
|
||||
let best: { name: string; kind: WorkspaceKind; len: number } | null = null;
|
||||
for (const r of spec.repos) {
|
||||
if (isInside(cwd, r.path) && r.path.length > (best?.len ?? -1)) {
|
||||
best = { name: r.name, kind: r.kind, len: r.path.length };
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
activeRepo = best.name;
|
||||
kind = best.kind;
|
||||
} else if (spec.knowledgeRoot && isInside(cwd, spec.knowledgeRoot)) {
|
||||
kind = "knowledge";
|
||||
}
|
||||
}
|
||||
// Fall back to the rig's default_repo when cwd doesn't resolve.
|
||||
if (!activeRepo && spec.defaultRepo) {
|
||||
activeRepo = spec.defaultRepo;
|
||||
if (!kind) {
|
||||
const r = spec.repos.find((x) => x.name === spec.defaultRepo);
|
||||
if (r) kind = r.kind;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceRoot: spec.workspaceRoot,
|
||||
activeRepo,
|
||||
kind,
|
||||
};
|
||||
}
|
||||
|
||||
function isInside(child: string, parent: string): boolean {
|
||||
const normChild = path.resolve(child);
|
||||
const normParent = path.resolve(parent);
|
||||
if (normChild === normParent) return true;
|
||||
const rel = path.relative(normParent, normChild);
|
||||
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||
}
|
||||
@@ -34,6 +34,38 @@ export function queueRoutes(): Hono {
|
||||
return c.get("eventBus" as never) as EventBus;
|
||||
}
|
||||
|
||||
/** PL-007: validate `target_repo` against the source rig's typed
|
||||
* workspace block. Returns 3-part structured error when the repo name
|
||||
* does not match the source rig's RigSpec.workspace.repos[]. Sessions
|
||||
* not associated with a workspace-bearing rig pass-through (target_repo
|
||||
* is honored as a free-form tag for back-compat). */
|
||||
function validateTargetRepo(
|
||||
c: { get: (key: string) => unknown },
|
||||
sourceSession: string,
|
||||
targetRepo: string,
|
||||
): { ok: true } | { ok: false; error: string; message: string; meta?: Record<string, unknown> } {
|
||||
const rigRepo = c.get("rigRepo" as never) as import("../domain/rig-repository.js").RigRepository | undefined;
|
||||
if (!rigRepo) return { ok: true };
|
||||
const m = /^[^@]+@(.+)$/.exec(sourceSession);
|
||||
if (!m) return { ok: true };
|
||||
const rigName = m[1]!;
|
||||
const rigs = rigRepo.findRigsByName(rigName);
|
||||
if (rigs.length === 0) return { ok: true };
|
||||
const rigId = rigs[0]!.id;
|
||||
const ws = rigRepo.getRigWorkspace(rigId);
|
||||
if (!ws) return { ok: true };
|
||||
const known = ws.repos.map((r) => r.name);
|
||||
if (!known.includes(targetRepo)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "unknown_target_repo",
|
||||
message: `target_repo "${targetRepo}" does not match any repo in rig ${rigName}'s workspace; check rig whoami --json | jq .workspace.repos to see declared repos`,
|
||||
meta: { rigName, knownRepos: known },
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function errorResponse(c: { json: (body: unknown, status?: number) => Response }, err: unknown): Response {
|
||||
if (err instanceof QueueRepositoryError) {
|
||||
const status = err.code === "qitem_not_found" ? 404
|
||||
@@ -75,12 +107,19 @@ export function queueRoutes(): Hono {
|
||||
tags?: string[];
|
||||
expiresAt?: string;
|
||||
chainOfRecord?: string[];
|
||||
targetRepo?: string;
|
||||
}>().catch(() => ({} as never));
|
||||
|
||||
if (!body.sourceSession) return c.json({ error: "sourceSession is required" }, 400);
|
||||
if (!body.destinationSession) return c.json({ error: "destinationSession is required" }, 400);
|
||||
if (!body.body) return c.json({ error: "body is required" }, 400);
|
||||
|
||||
// PL-007: validate target_repo against source rig's workspace.repos[].
|
||||
if (body.targetRepo) {
|
||||
const validation = validateTargetRepo(c, body.sourceSession, body.targetRepo);
|
||||
if (!validation.ok) return c.json({ error: validation.error, message: validation.message, ...(validation.meta ?? {}) }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const item = await getRepo(c).create({
|
||||
qitemId: body.qitemId,
|
||||
@@ -92,6 +131,7 @@ export function queueRoutes(): Hono {
|
||||
tags: body.tags,
|
||||
expiresAt: body.expiresAt,
|
||||
chainOfRecord: body.chainOfRecord,
|
||||
targetRepo: body.targetRepo,
|
||||
nudge: (body as { nudge?: boolean }).nudge,
|
||||
});
|
||||
return c.json(item, 201);
|
||||
@@ -165,10 +205,16 @@ export function queueRoutes(): Hono {
|
||||
priority?: QueuePriority;
|
||||
tier?: string;
|
||||
tags?: string[];
|
||||
targetRepo?: string;
|
||||
}>().catch(() => ({} as never));
|
||||
if (!body.fromSession) return c.json({ error: "fromSession is required" }, 400);
|
||||
if (!body.toSession) return c.json({ error: "toSession is required" }, 400);
|
||||
|
||||
if (body.targetRepo) {
|
||||
const validation = validateTargetRepo(c, body.fromSession, body.targetRepo);
|
||||
if (!validation.ok) return c.json({ error: validation.error, message: validation.message, ...(validation.meta ?? {}) }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getRepo(c).handoff({
|
||||
qitemId,
|
||||
@@ -179,6 +225,7 @@ export function queueRoutes(): Hono {
|
||||
priority: body.priority,
|
||||
tier: body.tier,
|
||||
tags: body.tags,
|
||||
targetRepo: body.targetRepo,
|
||||
nudge: (body as { nudge?: boolean }).nudge,
|
||||
});
|
||||
return c.json(result, 201);
|
||||
@@ -201,10 +248,16 @@ export function queueRoutes(): Hono {
|
||||
tier?: string;
|
||||
tags?: string[];
|
||||
nudge?: boolean;
|
||||
targetRepo?: string;
|
||||
}>().catch(() => ({} as never));
|
||||
if (!body.fromSession) return c.json({ error: "fromSession is required" }, 400);
|
||||
if (!body.toSession) return c.json({ error: "toSession is required" }, 400);
|
||||
|
||||
if (body.targetRepo) {
|
||||
const validation = validateTargetRepo(c, body.fromSession, body.targetRepo);
|
||||
if (!validation.ok) return c.json({ error: validation.error, message: validation.message, ...(validation.meta ?? {}) }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getRepo(c).handoffAndComplete({
|
||||
qitemId,
|
||||
@@ -215,6 +268,7 @@ export function queueRoutes(): Hono {
|
||||
priority: body.priority,
|
||||
tier: body.tier,
|
||||
tags: body.tags,
|
||||
targetRepo: body.targetRepo,
|
||||
nudge: body.nudge,
|
||||
});
|
||||
return c.json(result, 201);
|
||||
@@ -252,9 +306,10 @@ export function queueRoutes(): Hono {
|
||||
const destinationSession = c.req.query("destinationSession") || undefined;
|
||||
const sourceSession = c.req.query("sourceSession") || undefined;
|
||||
const stateRaw = c.req.query("state") || undefined;
|
||||
const targetRepo = c.req.query("targetRepo") || undefined;
|
||||
const limit = c.req.query("limit") ? Number.parseInt(c.req.query("limit")!, 10) : undefined;
|
||||
const state = stateRaw ? (stateRaw.split(",") as QueueState[]) : undefined;
|
||||
const items = getRepo(c).list({ destinationSession, sourceSession, state, limit });
|
||||
const items = getRepo(c).list({ destinationSession, sourceSession, state, targetRepo, limit });
|
||||
return c.json(items);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// PL-007 Workspace Primitive v0 — workspace HTTP routes.
|
||||
//
|
||||
// One read-only endpoint at v0:
|
||||
//
|
||||
// POST /api/workspace/validate
|
||||
// body: { root: string; workspaceKind?: WorkspaceKind;
|
||||
// recursive?: boolean; requireFrontmatter?: boolean;
|
||||
// maxFiles?: number }
|
||||
// response: FrontmatterValidationReport
|
||||
//
|
||||
// No filesystem mutation. Operator picks the root + kind per invocation.
|
||||
//
|
||||
// Whoami / node-inventory surface workspace data through their existing
|
||||
// routes; no separate /api/workspace/whoami.
|
||||
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
validateWorkspaceFrontmatter,
|
||||
type ValidateOpts,
|
||||
} from "../domain/workspace/frontmatter-validator.js";
|
||||
import { WORKSPACE_KINDS, type WorkspaceKind } from "../domain/types.js";
|
||||
|
||||
export function workspaceRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/validate", async (c) => {
|
||||
const body = await c.req.json<{
|
||||
root?: string;
|
||||
workspaceKind?: string;
|
||||
recursive?: boolean;
|
||||
requireFrontmatter?: boolean;
|
||||
maxFiles?: number;
|
||||
}>().catch(() => ({} as never));
|
||||
|
||||
if (!body.root || typeof body.root !== "string") {
|
||||
return c.json({ error: "root_required", message: "root is required" }, 400);
|
||||
}
|
||||
let kind: WorkspaceKind | undefined;
|
||||
if (body.workspaceKind !== undefined) {
|
||||
if (!(WORKSPACE_KINDS as readonly string[]).includes(body.workspaceKind)) {
|
||||
return c.json({
|
||||
error: "invalid_workspace_kind",
|
||||
message: `workspaceKind must be one of: ${[...WORKSPACE_KINDS].join(", ")}`,
|
||||
}, 400);
|
||||
}
|
||||
kind = body.workspaceKind as WorkspaceKind;
|
||||
}
|
||||
|
||||
const opts: ValidateOpts = {
|
||||
root: body.root,
|
||||
...(kind !== undefined ? { workspaceKind: kind } : {}),
|
||||
...(body.recursive !== undefined ? { recursive: body.recursive } : {}),
|
||||
...(body.requireFrontmatter !== undefined ? { requireFrontmatter: body.requireFrontmatter } : {}),
|
||||
...(body.maxFiles !== undefined ? { maxFiles: body.maxFiles } : {}),
|
||||
};
|
||||
try {
|
||||
const report = validateWorkspaceFrontmatter(opts);
|
||||
return c.json(report);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "internal error";
|
||||
return c.json({ error: "validate_failed", message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import type { WhoamiService } from "./domain/whoami-service.js";
|
||||
import { chatRoutes } from "./routes/chat.js";
|
||||
import { streamRoutes } from "./routes/stream.js";
|
||||
import { queueRoutes } from "./routes/queue.js";
|
||||
import { workspaceRoutes } from "./routes/workspace.js";
|
||||
import { projectsRoutes } from "./routes/projects.js";
|
||||
import { viewsRoutes } from "./routes/views.js";
|
||||
import { watchdogRoutes } from "./routes/watchdog.js";
|
||||
@@ -424,6 +425,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/rigs/:rigId/chat", chatRoutes());
|
||||
app.route("/api/stream", streamRoutes());
|
||||
app.route("/api/queue", queueRoutes());
|
||||
app.route("/api/workspace", workspaceRoutes());
|
||||
app.route("/api/projects", projectsRoutes());
|
||||
app.route("/api/views", viewsRoutes());
|
||||
app.route("/api/watchdog", watchdogRoutes());
|
||||
|
||||
@@ -120,6 +120,8 @@ import { workflowInstancesSchema } from "./db/migrations/034_workflow_instances.
|
||||
import { workflowStepTrailsSchema } from "./db/migrations/035_workflow_step_trails.js";
|
||||
import { watchdogPolicyEnumExtensionSchema } from "./db/migrations/036_watchdog_policy_enum_extension.js";
|
||||
import { missionControlActionsSchema } from "./db/migrations/037_mission_control_actions.js";
|
||||
import { workspacePrimitiveSchema } from "./db/migrations/038_workspace_primitive.js";
|
||||
import { queueTargetRepoSchema } from "./db/migrations/039_queue_target_repo.js";
|
||||
import { MissionControlActionLog } from "./domain/mission-control/mission-control-action-log.js";
|
||||
import { MissionControlWriteContract } from "./domain/mission-control/mission-control-write-contract.js";
|
||||
import { MissionControlReadLayer } from "./domain/mission-control/mission-control-read-layer.js";
|
||||
@@ -165,7 +167,7 @@ interface DaemonResult {
|
||||
export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult> {
|
||||
const dbPath = opts?.dbPath ?? ":memory:";
|
||||
const db = createDb(dbPath);
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, projectClassificationsSchema, classifierLeasesSchema, viewsCustomSchema, watchdogJobsSchema, watchdogHistorySchema, workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema, watchdogPolicyEnumExtensionSchema, missionControlActionsSchema]);
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, projectClassificationsSchema, classifierLeasesSchema, viewsCustomSchema, watchdogJobsSchema, watchdogHistorySchema, workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema, watchdogPolicyEnumExtensionSchema, missionControlActionsSchema, workspacePrimitiveSchema, queueTargetRepoSchema]);
|
||||
|
||||
const rigRepo = new RigRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// PL-007 Workspace Primitive v0 — queue_items.target_repo round-trip
|
||||
// tests.
|
||||
//
|
||||
// Pins:
|
||||
// - create with targetRepo persists + roundtrips through getById
|
||||
// - list filters by targetRepo
|
||||
// - handoff inherits source's targetRepo when not overridden
|
||||
// - handoff-and-complete inherits source's targetRepo when not
|
||||
// overridden; explicit override wins
|
||||
// - migration 038 column present after migrate runs
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { queueTargetRepoSchema } from "../src/db/migrations/039_queue_target_repo.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
|
||||
let db: Database.Database;
|
||||
let repo: QueueRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, queueTargetRepoSchema]);
|
||||
repo = new QueueRepository(db, new EventBus(db));
|
||||
});
|
||||
|
||||
describe("queue target_repo (PL-007)", () => {
|
||||
it("migration 038 adds target_repo column", () => {
|
||||
const cols = db.prepare("PRAGMA table_info(queue_items)").all() as Array<{ name: string }>;
|
||||
expect(cols.some((c) => c.name === "target_repo")).toBe(true);
|
||||
});
|
||||
|
||||
it("create persists target_repo and getById roundtrips it", async () => {
|
||||
const created = await repo.create({
|
||||
sourceSession: "alice@rigA",
|
||||
destinationSession: "bob@rigA",
|
||||
body: "test",
|
||||
targetRepo: "openrig",
|
||||
});
|
||||
expect(created.targetRepo).toBe("openrig");
|
||||
const fetched = repo.getById(created.qitemId);
|
||||
expect(fetched?.targetRepo).toBe("openrig");
|
||||
});
|
||||
|
||||
it("create without targetRepo persists null", async () => {
|
||||
const created = await repo.create({
|
||||
sourceSession: "alice@rigA",
|
||||
destinationSession: "bob@rigA",
|
||||
body: "test",
|
||||
});
|
||||
expect(created.targetRepo).toBeNull();
|
||||
});
|
||||
|
||||
it("list filters by targetRepo", async () => {
|
||||
await repo.create({ sourceSession: "a@r", destinationSession: "b@r", body: "x", targetRepo: "openrig" });
|
||||
await repo.create({ sourceSession: "a@r", destinationSession: "b@r", body: "y", targetRepo: "internal" });
|
||||
await repo.create({ sourceSession: "a@r", destinationSession: "b@r", body: "z" });
|
||||
const filtered = repo.list({ targetRepo: "openrig" });
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0]?.targetRepo).toBe("openrig");
|
||||
});
|
||||
|
||||
it("handoff inherits source's targetRepo when not overridden", async () => {
|
||||
const src = await repo.create({
|
||||
sourceSession: "a@r",
|
||||
destinationSession: "b@r",
|
||||
body: "x",
|
||||
targetRepo: "openrig",
|
||||
});
|
||||
const result = await repo.handoff({
|
||||
qitemId: src.qitemId,
|
||||
fromSession: "b@r",
|
||||
toSession: "c@r",
|
||||
});
|
||||
expect(result.created.targetRepo).toBe("openrig");
|
||||
});
|
||||
|
||||
it("handoff explicit targetRepo overrides source", async () => {
|
||||
const src = await repo.create({
|
||||
sourceSession: "a@r",
|
||||
destinationSession: "b@r",
|
||||
body: "x",
|
||||
targetRepo: "openrig",
|
||||
});
|
||||
const result = await repo.handoff({
|
||||
qitemId: src.qitemId,
|
||||
fromSession: "b@r",
|
||||
toSession: "c@r",
|
||||
targetRepo: "internal",
|
||||
});
|
||||
expect(result.created.targetRepo).toBe("internal");
|
||||
});
|
||||
|
||||
it("handoffAndComplete inherits source's targetRepo when not overridden", async () => {
|
||||
const src = await repo.create({
|
||||
sourceSession: "a@r",
|
||||
destinationSession: "b@r",
|
||||
body: "x",
|
||||
targetRepo: "openrig",
|
||||
});
|
||||
const result = await repo.handoffAndComplete({
|
||||
qitemId: src.qitemId,
|
||||
fromSession: "b@r",
|
||||
toSession: "c@r",
|
||||
});
|
||||
expect(result.created.targetRepo).toBe("openrig");
|
||||
expect(result.closed.state).toBe("done");
|
||||
});
|
||||
|
||||
it("legacy fixture without migration 038: degrades gracefully (target_repo NOT persisted, no throws)", async () => {
|
||||
const legacyDb = createDb();
|
||||
migrate(legacyDb, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema]);
|
||||
const legacyRepo = new QueueRepository(legacyDb, new EventBus(legacyDb));
|
||||
// The schema-detect should be false; INSERTs should run the legacy
|
||||
// statement; targetRepo input is silently dropped.
|
||||
const created = await legacyRepo.create({
|
||||
sourceSession: "a@r",
|
||||
destinationSession: "b@r",
|
||||
body: "x",
|
||||
targetRepo: "openrig",
|
||||
});
|
||||
expect(created.targetRepo).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
// PL-007 Workspace Primitive v0 — RigSpec.workspace block validation +
|
||||
// normalization tests.
|
||||
//
|
||||
// Pins:
|
||||
// - validateRigSpec accepts an optional workspace block with required
|
||||
// workspace_root + repos[] + per-repo (name, path, kind)
|
||||
// - rejects malformed (missing workspace_root, unknown kind, duplicate
|
||||
// repo name, default_repo not in repos[])
|
||||
// - back-compat: rigs without workspace block stay valid
|
||||
// - normalize round-trips fields and resolves relative paths against
|
||||
// workspace_root
|
||||
// - codec round-trip preserves the workspace block on serialize/parse
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { RigSpecSchema } from "../src/domain/rigspec-schema.js";
|
||||
import { RigSpecCodec } from "../src/domain/rigspec-codec.js";
|
||||
|
||||
const baseRig = {
|
||||
format: "pod_aware",
|
||||
version: "0.2",
|
||||
name: "test-rig",
|
||||
pods: [{
|
||||
id: "dev",
|
||||
label: "Dev pod",
|
||||
members: [{
|
||||
id: "impl",
|
||||
agent_ref: "local:agents/impl",
|
||||
profile: "default",
|
||||
runtime: "claude-code",
|
||||
cwd: "/tmp",
|
||||
}],
|
||||
edges: [],
|
||||
}],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
const workspaceBlock = {
|
||||
workspace_root: "/Users/test/project",
|
||||
repos: [
|
||||
{ name: "main", path: "main", kind: "project" },
|
||||
{ name: "internal", path: "/Users/test/project/internal", kind: "project" },
|
||||
],
|
||||
default_repo: "main",
|
||||
knowledge_root: "/Users/test/knowledge",
|
||||
};
|
||||
|
||||
describe("RigSpec validation — workspace block (PL-007)", () => {
|
||||
it("accepts a well-formed workspace block", () => {
|
||||
const result = RigSpecSchema.validate({ ...baseRig, workspace: workspaceBlock });
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("back-compat: rig without workspace block stays valid", () => {
|
||||
const result = RigSpecSchema.validate(baseRig);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing workspace_root", () => {
|
||||
const broken = { ...baseRig, workspace: { ...workspaceBlock, workspace_root: "" } };
|
||||
const result = RigSpecSchema.validate(broken);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("workspace_root"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unknown kind", () => {
|
||||
const broken = {
|
||||
...baseRig,
|
||||
workspace: { ...workspaceBlock, repos: [{ name: "x", path: "x", kind: "rd-pod" }] },
|
||||
};
|
||||
const result = RigSpecSchema.validate(broken);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => /kind/i.test(e))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate repo names", () => {
|
||||
const broken = {
|
||||
...baseRig,
|
||||
workspace: {
|
||||
...workspaceBlock,
|
||||
repos: [
|
||||
{ name: "openrig", path: "openrig", kind: "project" },
|
||||
{ name: "openrig", path: "openrig-internal", kind: "project" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = RigSpecSchema.validate(broken);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => /duplicate/i.test(e))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects default_repo that does not match any repo", () => {
|
||||
const broken = { ...baseRig, workspace: { ...workspaceBlock, default_repo: "nonexistent" } };
|
||||
const result = RigSpecSchema.validate(broken);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => /default_repo/.test(e))).toBe(true);
|
||||
});
|
||||
|
||||
it("normalize resolves relative repo paths against workspace_root", () => {
|
||||
const normalized = RigSpecSchema.normalize({ ...baseRig, workspace: workspaceBlock } as Record<string, unknown>);
|
||||
expect(normalized.workspace).toBeDefined();
|
||||
expect(normalized.workspace?.workspaceRoot).toBe("/Users/test/project");
|
||||
expect(normalized.workspace?.repos[0]?.path).toBe("/Users/test/project/main");
|
||||
expect(normalized.workspace?.repos[1]?.path).toBe("/Users/test/project/internal");
|
||||
expect(normalized.workspace?.defaultRepo).toBe("main");
|
||||
expect(normalized.workspace?.knowledgeRoot).toBe("/Users/test/knowledge");
|
||||
});
|
||||
|
||||
it("normalize accepts a rig without workspace block", () => {
|
||||
const normalized = RigSpecSchema.normalize(baseRig as Record<string, unknown>);
|
||||
expect(normalized.workspace).toBeUndefined();
|
||||
});
|
||||
|
||||
it("validates all 5 typed kinds (user/project/knowledge/lab/delivery)", () => {
|
||||
for (const k of ["user", "project", "knowledge", "lab", "delivery"]) {
|
||||
const result = RigSpecSchema.validate({
|
||||
...baseRig,
|
||||
workspace: {
|
||||
workspace_root: "/r",
|
||||
repos: [{ name: "a", path: "a", kind: k }],
|
||||
},
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("codec round-trips the workspace block", () => {
|
||||
const normalized = RigSpecSchema.normalize({ ...baseRig, workspace: workspaceBlock } as Record<string, unknown>);
|
||||
const yaml = RigSpecCodec.serialize(normalized);
|
||||
const parsed = RigSpecCodec.parse(yaml) as Record<string, unknown>;
|
||||
expect((parsed.workspace as Record<string, unknown>).workspace_root).toBe("/Users/test/project");
|
||||
const repos = (parsed.workspace as Record<string, unknown>).repos as Array<Record<string, unknown>>;
|
||||
expect(repos).toHaveLength(2);
|
||||
expect(repos[0]?.name).toBe("main");
|
||||
expect(repos[0]?.kind).toBe("project");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// PL-007 Workspace Primitive v0 — whoami workspace block tests.
|
||||
//
|
||||
// Pins:
|
||||
// - whoami returns workspace block populated when rig has workspace
|
||||
// declared via setRigWorkspace
|
||||
// - whoami returns workspace=null when rig has no workspace
|
||||
// - back-compat: legacy fixture without migration 038 returns null
|
||||
// - per-node workspace.activeRepo resolves from cwd against repos[]
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { snapshotsSchema } from "../src/db/migrations/004_snapshots.js";
|
||||
import { checkpointsSchema } from "../src/db/migrations/005_checkpoints.js";
|
||||
import { resumeMetadataSchema } from "../src/db/migrations/006_resume_metadata.js";
|
||||
import { nodeSpecFieldsSchema } from "../src/db/migrations/007_node_spec_fields.js";
|
||||
import { agentspecRebootSchema } from "../src/db/migrations/014_agentspec_reboot.js";
|
||||
import { podNamespaceSchema } from "../src/db/migrations/017_pod_namespace.js";
|
||||
import { externalCliAttachmentSchema } from "../src/db/migrations/019_external_cli_attachment.js";
|
||||
import { workspacePrimitiveSchema } from "../src/db/migrations/038_workspace_primitive.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { WhoamiService } from "../src/domain/whoami-service.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { TranscriptStore } from "../src/domain/transcript-store.js";
|
||||
|
||||
let db: Database.Database;
|
||||
let rigRepo: RigRepository;
|
||||
let whoami: WhoamiService;
|
||||
|
||||
function makeFixture(withWorkspaceMigration: boolean): void {
|
||||
db = createDb();
|
||||
const migs = [
|
||||
coreSchema,
|
||||
bindingsSessionsSchema,
|
||||
eventsSchema,
|
||||
snapshotsSchema,
|
||||
checkpointsSchema,
|
||||
resumeMetadataSchema,
|
||||
nodeSpecFieldsSchema,
|
||||
agentspecRebootSchema,
|
||||
podNamespaceSchema,
|
||||
externalCliAttachmentSchema,
|
||||
];
|
||||
if (withWorkspaceMigration) migs.push(workspacePrimitiveSchema);
|
||||
migrate(db, migs);
|
||||
rigRepo = new RigRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
const transcriptStore = new TranscriptStore({ enabled: false, baseDir: "/tmp" });
|
||||
whoami = new WhoamiService({ db, rigRepo, sessionRegistry, transcriptStore });
|
||||
}
|
||||
|
||||
beforeEach(() => makeFixture(true));
|
||||
|
||||
describe("whoami workspace block (PL-007)", () => {
|
||||
it("returns workspace block when rig has workspace declared", () => {
|
||||
const rig = rigRepo.createRig("alpha-rig");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code", cwd: "/Users/op/hub/main/sub" });
|
||||
rigRepo.setRigWorkspace(rig.id, {
|
||||
workspaceRoot: "/Users/op/hub",
|
||||
repos: [
|
||||
{ name: "main", path: "/Users/op/hub/main", kind: "project" },
|
||||
],
|
||||
defaultRepo: "main",
|
||||
knowledgeRoot: "/Users/op/knowledge",
|
||||
});
|
||||
|
||||
const result = whoami.resolve({ nodeId: node.id });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.workspace).toBeDefined();
|
||||
expect(result?.workspace?.workspaceRoot).toBe("/Users/op/hub");
|
||||
expect(result?.workspace?.activeRepo).toBe("main");
|
||||
expect(result?.workspace?.knowledgeKind).toBe("knowledge");
|
||||
});
|
||||
|
||||
it("returns workspace=null when rig has no workspace declared", () => {
|
||||
const rig = rigRepo.createRig("plain-rig");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code", cwd: "/x" });
|
||||
|
||||
const result = whoami.resolve({ nodeId: node.id });
|
||||
expect(result?.workspace).toBeNull();
|
||||
});
|
||||
|
||||
it("back-compat: legacy fixture without migration 038 surfaces workspace=null (no throws)", () => {
|
||||
makeFixture(false);
|
||||
const rig = rigRepo.createRig("legacy-rig");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code", cwd: "/x" });
|
||||
// setRigWorkspace is a no-op when column absent
|
||||
rigRepo.setRigWorkspace(rig.id, {
|
||||
workspaceRoot: "/Users/op/hub",
|
||||
repos: [{ name: "main", path: "/Users/op/hub/main", kind: "project" }],
|
||||
});
|
||||
|
||||
const result = whoami.resolve({ nodeId: node.id });
|
||||
expect(result?.workspace).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// PL-007 Workspace Primitive v0 — frontmatter validator tests.
|
||||
//
|
||||
// Pins:
|
||||
// - missing-required-field per kind for knowledge canon
|
||||
// - unrecognized-status-value when status is not in the allowed enum
|
||||
// - parse-error when frontmatter is malformed YAML
|
||||
// - clean canon (every field present + status valid) returns 0 gaps
|
||||
// - non-recursive scope walks only top-level
|
||||
// - skips noise dirs (node_modules / .git)
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { validateWorkspaceFrontmatter } from "../src/domain/workspace/frontmatter-validator.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
function write(rel: string, contents: string): void {
|
||||
const full = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, contents, "utf-8");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "pl007-fmv-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe("validateWorkspaceFrontmatter (PL-007)", () => {
|
||||
it("clean knowledge canon: 0 gaps", () => {
|
||||
write("alpha.md", "---\ndoc: alpha\nstatus: active\ncreated: 2026-05-04\nowner: someone\n---\n# Alpha\n");
|
||||
write("beta.md", "---\ndoc: beta\nstatus: draft\ncreated: 2026-05-04\nowner: someone\n---\n# Beta\n");
|
||||
const r = validateWorkspaceFrontmatter({ root: dir, workspaceKind: "knowledge" });
|
||||
expect(r.totalFiles).toBe(2);
|
||||
expect(r.filesWithFrontmatter).toBe(2);
|
||||
expect(r.gapCount).toBe(0);
|
||||
});
|
||||
|
||||
it("missing-required-field for knowledge canon (no owner)", () => {
|
||||
write("alpha.md", "---\ndoc: alpha\nstatus: active\ncreated: 2026-05-04\n---\n# alpha\n");
|
||||
const r = validateWorkspaceFrontmatter({ root: dir, workspaceKind: "knowledge" });
|
||||
expect(r.gapCount).toBe(1);
|
||||
expect(r.gaps[0]?.kind).toBe("missing-required-field");
|
||||
expect(r.gaps[0]?.field).toBe("owner");
|
||||
});
|
||||
|
||||
it("unrecognized-status-value when status is invalid", () => {
|
||||
write("a.md", "---\ndoc: a\nstatus: in-flight\ncreated: 2026-05-04\nowner: x\n---\n");
|
||||
const r = validateWorkspaceFrontmatter({ root: dir, workspaceKind: "knowledge" });
|
||||
expect(r.gaps.some((g) => g.kind === "unrecognized-status-value")).toBe(true);
|
||||
});
|
||||
|
||||
it("parse-error when frontmatter YAML is malformed", () => {
|
||||
write("a.md", "---\nthis: is\n bad: ye:s::\n---\n");
|
||||
const r = validateWorkspaceFrontmatter({ root: dir, workspaceKind: "knowledge" });
|
||||
expect(r.gaps.some((g) => g.kind === "parse-error")).toBe(true);
|
||||
});
|
||||
|
||||
it("user kind has lighter required set", () => {
|
||||
write("a.md", "---\ndoc: u\n---\n# u\n");
|
||||
const r = validateWorkspaceFrontmatter({ root: dir, workspaceKind: "user" });
|
||||
expect(r.gapCount).toBe(0);
|
||||
});
|
||||
|
||||
it("missing-frontmatter is silent by default; reported when requireFrontmatter=true", () => {
|
||||
write("a.md", "# alpha\nplain markdown, no fm\n");
|
||||
const def = validateWorkspaceFrontmatter({ root: dir, workspaceKind: "knowledge" });
|
||||
expect(def.gapCount).toBe(0);
|
||||
const strict = validateWorkspaceFrontmatter({ root: dir, workspaceKind: "knowledge", requireFrontmatter: true });
|
||||
expect(strict.gaps.some((g) => g.kind === "missing-frontmatter")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips node_modules and .git noise dirs", () => {
|
||||
write("node_modules/a.md", "---\ndoc: a\n---\n");
|
||||
write(".git/b.md", "---\ndoc: b\n---\n");
|
||||
write("real.md", "---\ndoc: real\nstatus: active\ncreated: 2026-05-04\nowner: x\n---\n");
|
||||
const r = validateWorkspaceFrontmatter({ root: dir, workspaceKind: "knowledge" });
|
||||
expect(r.totalFiles).toBe(1);
|
||||
});
|
||||
|
||||
it("non-recursive walks only top-level", () => {
|
||||
write("a.md", "---\ndoc: a\n---\n");
|
||||
write("nested/b.md", "---\ndoc: b\n---\n");
|
||||
const r = validateWorkspaceFrontmatter({ root: dir, recursive: false });
|
||||
expect(r.totalFiles).toBe(1);
|
||||
});
|
||||
|
||||
it("never modifies any input file (advisory contract)", () => {
|
||||
const content = "---\ndoc: a\nstatus: rotten\n---\nbody\n";
|
||||
write("a.md", content);
|
||||
validateWorkspaceFrontmatter({ root: dir, workspaceKind: "knowledge" });
|
||||
expect(fs.readFileSync(path.join(dir, "a.md"), "utf-8")).toBe(content);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
// PL-007 Workspace Primitive v0 — workspace-resolver tests.
|
||||
//
|
||||
// Pins:
|
||||
// - resolveWorkspaceContext returns null when no spec
|
||||
// - activeRepo from default_repo when present in repos[]
|
||||
// - env override wins over default_repo when set
|
||||
// - knowledgeKind = "knowledge" when knowledge_root declared
|
||||
// - resolveNodeWorkspace longest-prefix wins for cwd containing
|
||||
// multiple repo paths
|
||||
// - knowledge fallback when cwd is under knowledge_root
|
||||
// - falls back to default_repo when cwd is outside repos and knowledge
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveWorkspaceContext, resolveNodeWorkspace } from "../src/domain/workspace/workspace-resolver.js";
|
||||
import type { WorkspaceSpec } from "../src/domain/types.js";
|
||||
|
||||
const spec: WorkspaceSpec = {
|
||||
workspaceRoot: "/Users/op/hub",
|
||||
repos: [
|
||||
{ name: "main", path: "/Users/op/hub/main", kind: "project" },
|
||||
{ name: "internal", path: "/Users/op/hub/main/sub", kind: "project" },
|
||||
{ name: "lab", path: "/Users/op/hub/lab", kind: "lab" },
|
||||
],
|
||||
defaultRepo: "main",
|
||||
knowledgeRoot: "/Users/op/knowledge",
|
||||
};
|
||||
|
||||
describe("resolveWorkspaceContext (PL-007)", () => {
|
||||
it("returns null when spec is null", () => {
|
||||
expect(resolveWorkspaceContext({ spec: null, cwd: "/x", envOverride: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns workspace block with activeRepo from default_repo", () => {
|
||||
const r = resolveWorkspaceContext({ spec, cwd: "/Users/op/hub/main", envOverride: null });
|
||||
expect(r).not.toBeNull();
|
||||
expect(r?.activeRepo).toBe("main");
|
||||
expect(r?.workspaceRoot).toBe("/Users/op/hub");
|
||||
expect(r?.repos).toHaveLength(3);
|
||||
expect(r?.knowledgeRoot).toBe("/Users/op/knowledge");
|
||||
expect(r?.knowledgeKind).toBe("knowledge");
|
||||
});
|
||||
|
||||
it("env override wins over default_repo", () => {
|
||||
const r = resolveWorkspaceContext({ spec, cwd: "/x", envOverride: "internal" });
|
||||
expect(r?.activeRepo).toBe("internal");
|
||||
});
|
||||
|
||||
it("env override is honored verbatim even when not in repos[]", () => {
|
||||
const r = resolveWorkspaceContext({ spec, cwd: "/x", envOverride: "rare-repo" });
|
||||
expect(r?.activeRepo).toBe("rare-repo");
|
||||
});
|
||||
|
||||
it("knowledgeKind null when knowledge_root absent", () => {
|
||||
const noKnowledge: WorkspaceSpec = { ...spec, knowledgeRoot: undefined };
|
||||
const r = resolveWorkspaceContext({ spec: noKnowledge, cwd: "/x", envOverride: null });
|
||||
expect(r?.knowledgeKind).toBeNull();
|
||||
expect(r?.knowledgeRoot).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveNodeWorkspace (PL-007)", () => {
|
||||
it("longest-prefix wins when cwd is under nested repo paths", () => {
|
||||
const r = resolveNodeWorkspace({ spec, cwd: "/Users/op/hub/main/sub/file.ts" });
|
||||
expect(r?.activeRepo).toBe("internal");
|
||||
expect(r?.kind).toBe("project");
|
||||
});
|
||||
|
||||
it("matches outer repo when cwd is under it but not a nested one", () => {
|
||||
const r = resolveNodeWorkspace({ spec, cwd: "/Users/op/hub/main/other" });
|
||||
expect(r?.activeRepo).toBe("main");
|
||||
expect(r?.kind).toBe("project");
|
||||
});
|
||||
|
||||
it("returns kind=knowledge when cwd is under knowledge_root", () => {
|
||||
const r = resolveNodeWorkspace({ spec, cwd: "/Users/op/knowledge/canon" });
|
||||
expect(r?.kind).toBe("knowledge");
|
||||
// activeRepo falls through to default_repo when cwd doesn't resolve to a repo
|
||||
expect(r?.activeRepo).toBe("main");
|
||||
});
|
||||
|
||||
it("falls back to default_repo when cwd is outside everything", () => {
|
||||
const r = resolveNodeWorkspace({ spec, cwd: "/elsewhere" });
|
||||
expect(r?.activeRepo).toBe("main");
|
||||
expect(r?.kind).toBe("project");
|
||||
});
|
||||
|
||||
it("returns null when spec is null", () => {
|
||||
expect(resolveNodeWorkspace({ spec: null, cwd: "/x" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns workspaceRoot even when cwd is null", () => {
|
||||
const r = resolveNodeWorkspace({ spec, cwd: null });
|
||||
expect(r?.workspaceRoot).toBe("/Users/op/hub");
|
||||
expect(r?.activeRepo).toBe("main");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// PL-007 Workspace Primitive v0 — workspace HTTP route tests.
|
||||
//
|
||||
// Pins:
|
||||
// - POST /api/workspace/validate returns the structured gap report
|
||||
// - 400 on missing root
|
||||
// - 400 on invalid workspace kind
|
||||
// - kind-agnostic invocation (no workspaceKind) returns 0 gaps when no
|
||||
// contract enforced
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { workspaceRoutes } from "../src/routes/workspace.js";
|
||||
|
||||
let dir: string;
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "pl007-route-"));
|
||||
app = new Hono();
|
||||
app.route("/api/workspace", workspaceRoutes());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe("workspace HTTP routes (PL-007)", () => {
|
||||
it("POST /validate returns structured gap report on knowledge canon", async () => {
|
||||
fs.writeFileSync(path.join(dir, "a.md"), "---\ndoc: a\nstatus: active\ncreated: 2026-05-04\nowner: x\n---\n", "utf-8");
|
||||
fs.writeFileSync(path.join(dir, "missing.md"), "---\ndoc: m\nstatus: active\ncreated: 2026-05-04\n---\n", "utf-8");
|
||||
const res = await app.request("/api/workspace/validate", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ root: dir, workspaceKind: "knowledge" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { totalFiles: number; gapCount: number; gaps: Array<{ kind: string; field: string | null }> };
|
||||
expect(body.totalFiles).toBe(2);
|
||||
expect(body.gapCount).toBe(1);
|
||||
expect(body.gaps[0]?.field).toBe("owner");
|
||||
});
|
||||
|
||||
it("POST /validate rejects missing root with 400", async () => {
|
||||
const res = await app.request("/api/workspace/validate", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /validate rejects unknown workspace kind", async () => {
|
||||
const res = await app.request("/api/workspace/validate", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ root: dir, workspaceKind: "rd-pod" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /validate without workspaceKind runs structural-only check", async () => {
|
||||
fs.writeFileSync(path.join(dir, "a.md"), "---\ndoc: a\n---\n", "utf-8");
|
||||
const res = await app.request("/api/workspace/validate", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ root: dir }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { gapCount: number; workspaceKind: string | null };
|
||||
expect(body.gapCount).toBe(0);
|
||||
expect(body.workspaceKind).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import { useAgentImageLibrary, type AgentImageEntry } from "../hooks/useAgentIma
|
||||
import { usePsEntries } from "../hooks/usePsEntries.js";
|
||||
import { useExpandRig, useRemoveLibrarySpec, useRenameLibrarySpec, type ExpandRigResult } from "../hooks/mutations.js";
|
||||
import { ExpansionOutcome } from "./ExpansionOutcome.js";
|
||||
import { useWorkspace, type WhoamiWorkspaceUI } from "../hooks/useWorkspace.js";
|
||||
import { WorkspaceKindBadge, resolveKindForPath, type WorkspaceKindLabel } from "./WorkspaceKindBadge.js";
|
||||
|
||||
type LibraryFilter = "all" | "apps" | "rigs" | "agents" | "workflows" | "context-packs" | "agent-images";
|
||||
|
||||
@@ -47,6 +49,21 @@ function filterEntries(entries: UnifiedLibraryEntry[], filter: LibraryFilter): U
|
||||
}
|
||||
}
|
||||
|
||||
/** PL-007: classify a library entry by workspace kind.
|
||||
* user_file → "user"; workspace entries resolved against the rig's
|
||||
* RigSpec.workspace block; built-in entries return null (they ship
|
||||
* with the daemon and do not belong to operator workspace topology). */
|
||||
function deriveEntryWorkspaceKind(
|
||||
entry: UnifiedLibraryEntry,
|
||||
workspace: WhoamiWorkspaceUI | null,
|
||||
): WorkspaceKindLabel | null {
|
||||
if (entry.sourceType === "user_file") return "user";
|
||||
if (entry.sourceType === "workspace") {
|
||||
return resolveKindForPath(entry.sourcePath ?? null, workspace);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// PL-016: project AgentImageEntry into the row-render shape with a
|
||||
// summary line surfacing runtime + token estimate + fork count.
|
||||
function agentImageToUnified(entry: AgentImageEntry): UnifiedLibraryEntry {
|
||||
@@ -128,12 +145,14 @@ function LibraryList({
|
||||
onSelect,
|
||||
renderAction,
|
||||
renderExpanded,
|
||||
workspace,
|
||||
}: {
|
||||
title: string;
|
||||
entries: UnifiedLibraryEntry[];
|
||||
onSelect: (id: string) => void;
|
||||
renderAction?: (entry: UnifiedLibraryEntry) => ReactNode;
|
||||
renderExpanded?: (entry: UnifiedLibraryEntry) => ReactNode;
|
||||
workspace?: WhoamiWorkspaceUI | null;
|
||||
}) {
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
@@ -159,6 +178,10 @@ function LibraryList({
|
||||
<span className="font-mono text-[7px] uppercase tracking-[0.12em] text-stone-500 border border-stone-300/50 px-1 py-0.5">
|
||||
{typeBadge}
|
||||
</span>
|
||||
{(() => {
|
||||
const kind = deriveEntryWorkspaceKind(entry, workspace ?? null);
|
||||
return kind ? <WorkspaceKindBadge kind={kind} compact /> : null;
|
||||
})()}
|
||||
<span className="font-mono text-[7px] uppercase tracking-[0.12em] text-stone-400">
|
||||
{stability}
|
||||
</span>
|
||||
@@ -251,6 +274,7 @@ function AddToRigFlow({ entryId, onDone }: { entryId: string; onDone: () => void
|
||||
|
||||
export function SpecsPanel({ onClose }: SpecsPanelProps) {
|
||||
const navigate = useNavigate();
|
||||
const workspace = useWorkspace();
|
||||
const {
|
||||
activeTask,
|
||||
currentRigDraft,
|
||||
@@ -530,6 +554,7 @@ export function SpecsPanel({ onClose }: SpecsPanelProps) {
|
||||
onSelect={openLibraryEntry}
|
||||
renderAction={(entry) => renderLibraryAction(entry, entry.kind === "rig")}
|
||||
renderExpanded={renderLibraryExpanded}
|
||||
workspace={workspace.data ?? null}
|
||||
/>
|
||||
{addToRigEntryId && allLibrary.some((e) => e.id === addToRigEntryId) && (
|
||||
<AddToRigFlow entryId={addToRigEntryId} onDone={() => setAddToRigEntryId(null)} />
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// PL-007 Workspace Primitive v0 — shared workspace-kind label component.
|
||||
//
|
||||
// Renders a tiny badge for one of the 5 typed workspace kinds:
|
||||
// user / project / knowledge / lab / delivery. Used across Files,
|
||||
// Progress, Slices, Specs, and Steering surfaces to give consumers a
|
||||
// consistent at-a-glance signal of "what kind of workspace is this?".
|
||||
//
|
||||
// Visual: small uppercase mono tag, distinct color per kind. Sized to fit
|
||||
// in dense lists; `compact` prop strips to a single character glyph for
|
||||
// space-constrained rows.
|
||||
|
||||
export type WorkspaceKindLabel = "user" | "project" | "knowledge" | "lab" | "delivery";
|
||||
|
||||
const KIND_META: Record<WorkspaceKindLabel, { label: string; glyph: string; bg: string; fg: string; border: string }> = {
|
||||
user: { label: "USER", glyph: "U", bg: "bg-violet-50", fg: "text-violet-700", border: "border-violet-200" },
|
||||
project: { label: "PROJECT", glyph: "P", bg: "bg-blue-50", fg: "text-blue-700", border: "border-blue-200" },
|
||||
knowledge: { label: "KNOW", glyph: "K", bg: "bg-emerald-50", fg: "text-emerald-700", border: "border-emerald-200" },
|
||||
lab: { label: "LAB", glyph: "L", bg: "bg-amber-50", fg: "text-amber-700", border: "border-amber-200" },
|
||||
delivery: { label: "DELIVERY", glyph: "D", bg: "bg-rose-50", fg: "text-rose-700", border: "border-rose-200" },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
kind: WorkspaceKindLabel;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WorkspaceKindBadge({ kind, compact, className }: Props) {
|
||||
const meta = KIND_META[kind];
|
||||
if (!meta) return null;
|
||||
const base = `inline-block border ${meta.border} ${meta.bg} ${meta.fg} font-mono text-[8px] uppercase tracking-[0.14em] leading-none rounded-sm`;
|
||||
const sized = compact ? "px-1 py-[2px]" : "px-1.5 py-0.5";
|
||||
return (
|
||||
<span
|
||||
data-testid={`workspace-kind-badge-${kind}`}
|
||||
title={`workspace kind: ${kind}`}
|
||||
className={`${base} ${sized} ${className ?? ""}`.trim()}
|
||||
>
|
||||
{compact ? meta.glyph : meta.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a workspace kind for a given absolute path against a typed
|
||||
* workspace block. Returns null when no match (the path is outside any
|
||||
* declared repo or knowledge_root). Longest-prefix wins, mirroring the
|
||||
* daemon-side resolveNodeWorkspace logic. */
|
||||
export function resolveKindForPath(
|
||||
absolutePath: string | null | undefined,
|
||||
workspace: {
|
||||
repos: Array<{ name: string; path: string; kind: WorkspaceKindLabel }>;
|
||||
knowledgeRoot: string | null;
|
||||
} | null | undefined,
|
||||
): WorkspaceKindLabel | null {
|
||||
if (!absolutePath || !workspace) return null;
|
||||
let best: { kind: WorkspaceKindLabel; len: number } | null = null;
|
||||
for (const r of workspace.repos) {
|
||||
if (isInsideOrEq(absolutePath, r.path) && r.path.length > (best?.len ?? -1)) {
|
||||
best = { kind: r.kind, len: r.path.length };
|
||||
}
|
||||
}
|
||||
if (best) return best.kind;
|
||||
if (workspace.knowledgeRoot && isInsideOrEq(absolutePath, workspace.knowledgeRoot)) {
|
||||
return "knowledge";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isInsideOrEq(child: string, parent: string): boolean {
|
||||
if (!child || !parent) return false;
|
||||
if (child === parent) return true;
|
||||
return child.startsWith(parent.endsWith("/") ? parent : parent + "/");
|
||||
}
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
import { MarkdownViewer } from "../markdown/MarkdownViewer.js";
|
||||
import { SyntaxHighlight } from "../markdown/SyntaxHighlight.js";
|
||||
import { useSpecReview } from "../../hooks/useSteering.js";
|
||||
import { useWorkspace } from "../../hooks/useWorkspace.js";
|
||||
import { WorkspaceKindBadge, resolveKindForPath } from "../WorkspaceKindBadge.js";
|
||||
|
||||
const TEXT_LIKE_EXTENSIONS = new Set([".md", ".txt", ".log", ".yaml", ".yml", ".json", ".js", ".jsx", ".ts", ".tsx", ".py", ".sh", ".bash", ".sql", ".css", ".html"]);
|
||||
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
|
||||
@@ -38,6 +40,7 @@ function isUnavailable(data: unknown): data is { unavailable: true; error: strin
|
||||
|
||||
export function FilesWorkspace() {
|
||||
const roots = useFilesRoots();
|
||||
const workspace = useWorkspace();
|
||||
const [selectedRoot, setSelectedRoot] = useState<string | null>(null);
|
||||
const [currentPath, setCurrentPath] = useState<string>("");
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
@@ -66,7 +69,7 @@ export function FilesWorkspace() {
|
||||
</header>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<aside data-testid="files-tree-pane" className="w-72 shrink-0 overflow-y-auto border-r border-stone-200 bg-stone-50">
|
||||
<RootSelector roots={roots.data} isLoading={roots.isLoading} selectedRoot={selectedRoot} onSelect={setSelectedRoot} />
|
||||
<RootSelector roots={roots.data} isLoading={roots.isLoading} selectedRoot={selectedRoot} onSelect={setSelectedRoot} workspace={workspace.data ?? null} />
|
||||
{selectedRoot && (
|
||||
<>
|
||||
<Breadcrumbs root={selectedRoot} path={currentPath} onNavigate={setCurrentPath} />
|
||||
@@ -110,11 +113,13 @@ function RootSelector({
|
||||
isLoading,
|
||||
selectedRoot,
|
||||
onSelect,
|
||||
workspace,
|
||||
}: {
|
||||
roots: ReturnType<typeof useFilesRoots>["data"] | undefined;
|
||||
isLoading: boolean;
|
||||
selectedRoot: string | null;
|
||||
onSelect: (name: string) => void;
|
||||
workspace: import("../../hooks/useWorkspace.js").WhoamiWorkspaceUI | null;
|
||||
}) {
|
||||
if (isLoading) return <div className="p-3 font-mono text-[10px] text-stone-400">Loading roots…</div>;
|
||||
if (!roots) return null;
|
||||
@@ -138,22 +143,26 @@ function RootSelector({
|
||||
<div data-testid="files-root-selector" className="border-b border-stone-200 p-2">
|
||||
<div className="mb-1 font-mono text-[8px] uppercase tracking-[0.18em] text-stone-500">Roots</div>
|
||||
<ul>
|
||||
{roots.roots.map((r: AllowlistRoot) => (
|
||||
<li key={r.name}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`files-root-${r.name}`}
|
||||
data-active={selectedRoot === r.name}
|
||||
onClick={() => onSelect(r.name)}
|
||||
className={`block w-full px-2 py-1 text-left font-mono text-[10px] hover:bg-stone-100 ${
|
||||
selectedRoot === r.name ? "bg-stone-200/80 text-stone-900" : "text-stone-700"
|
||||
}`}
|
||||
title={r.path}
|
||||
>
|
||||
{r.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{roots.roots.map((r: AllowlistRoot) => {
|
||||
const kind = resolveKindForPath(r.path, workspace);
|
||||
return (
|
||||
<li key={r.name}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`files-root-${r.name}`}
|
||||
data-active={selectedRoot === r.name}
|
||||
onClick={() => onSelect(r.name)}
|
||||
className={`flex w-full items-center justify-between gap-2 px-2 py-1 text-left font-mono text-[10px] hover:bg-stone-100 ${
|
||||
selectedRoot === r.name ? "bg-stone-200/80 text-stone-900" : "text-stone-700"
|
||||
}`}
|
||||
title={r.path}
|
||||
>
|
||||
<span className="truncate">{r.name}</span>
|
||||
{kind && <WorkspaceKindBadge kind={kind} compact />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
type LintWarning,
|
||||
type PriorityRailLevel,
|
||||
} from "./priority-rail-rule.js";
|
||||
import { useWorkspace, type WhoamiWorkspaceUI } from "../../hooks/useWorkspace.js";
|
||||
import { WorkspaceKindBadge, resolveKindForPath } from "../WorkspaceKindBadge.js";
|
||||
|
||||
type StatusFilter = "all" | "active" | "done" | "blocked";
|
||||
|
||||
@@ -35,6 +37,7 @@ function isUnavailable(data: unknown): data is { unavailable: true; error: strin
|
||||
|
||||
export function ProgressWorkspace() {
|
||||
const tree = useProgressTree();
|
||||
const workspace = useWorkspace();
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedRow, setSelectedRow] = useState<{ file: ProgressFileNode; row: ProgressRow } | null>(null);
|
||||
@@ -160,6 +163,7 @@ export function ProgressWorkspace() {
|
||||
search={search}
|
||||
onRowClick={(row) => setSelectedRow({ file, row })}
|
||||
selectedRow={selectedRow}
|
||||
workspace={workspace.data ?? null}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
@@ -205,6 +209,7 @@ function ProgressFileSection({
|
||||
search,
|
||||
onRowClick,
|
||||
selectedRow,
|
||||
workspace,
|
||||
}: {
|
||||
file: ProgressFileNode;
|
||||
meta: FileMeta | undefined;
|
||||
@@ -213,6 +218,7 @@ function ProgressFileSection({
|
||||
search: string;
|
||||
onRowClick: (row: ProgressRow) => void;
|
||||
selectedRow: { file: ProgressFileNode; row: ProgressRow } | null;
|
||||
workspace: WhoamiWorkspaceUI | null;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const filteredRows = useMemo(() => filterRows(file.rows, statusFilter, search), [file.rows, statusFilter, search]);
|
||||
@@ -246,6 +252,10 @@ function ProgressFileSection({
|
||||
)}
|
||||
<span className="font-mono text-[11px] font-bold text-stone-900">{file.title ?? file.relPath}</span>
|
||||
<span className="font-mono text-[8px] uppercase tracking-[0.10em] text-stone-500">{file.rootName}</span>
|
||||
{(() => {
|
||||
const kind = resolveKindForPath(file.absolutePath, workspace);
|
||||
return kind ? <WorkspaceKindBadge kind={kind} compact /> : null;
|
||||
})()}
|
||||
{showLint && lintWarnings.length > 0 && (
|
||||
<span
|
||||
data-testid={`progress-file-${file.rootName}-${file.relPath}-lint-count`}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Link, useParams } from "@tanstack/react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useSlices, type SliceFilter, type SliceListEntry, type SliceListResponse, type SlicesUnavailable } from "../../hooks/useSlices.js";
|
||||
import { useActiveLens, clearActiveLens } from "../../hooks/useSpecLibrary.js";
|
||||
import { useWorkspace, type WhoamiWorkspaceUI } from "../../hooks/useWorkspace.js";
|
||||
import { WorkspaceKindBadge, resolveKindForPath } from "../WorkspaceKindBadge.js";
|
||||
|
||||
const FILTERS: SliceFilter[] = ["all", "active", "done", "blocked"];
|
||||
|
||||
@@ -22,6 +24,7 @@ export function SliceExplorerPanel({ onNavigate = () => {} }: { onNavigate?: ()
|
||||
const [filter, setFilter] = useState<SliceFilter>("active");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const workspace = useWorkspace();
|
||||
const { data: activeLens } = useActiveLens();
|
||||
const lensActive = !!activeLens && !showAll;
|
||||
const list = useSlices(
|
||||
@@ -109,6 +112,7 @@ export function SliceExplorerPanel({ onNavigate = () => {} }: { onNavigate?: ()
|
||||
slice={slice}
|
||||
selected={slice.name === selectedName}
|
||||
onNavigate={onNavigate}
|
||||
workspace={workspace.data ?? null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -120,10 +124,12 @@ function SliceListRow({
|
||||
slice,
|
||||
selected,
|
||||
onNavigate,
|
||||
workspace,
|
||||
}: {
|
||||
slice: SliceListEntry;
|
||||
selected: boolean;
|
||||
onNavigate: () => void;
|
||||
workspace: WhoamiWorkspaceUI | null;
|
||||
}) {
|
||||
const statusGlyph = slice.status === "done"
|
||||
? "D"
|
||||
@@ -148,6 +154,10 @@ function SliceListRow({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] text-stone-500" aria-label={`status ${slice.status}`}>{statusGlyph}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] font-semibold text-stone-900">{slice.name}</span>
|
||||
{(() => {
|
||||
const kind = resolveKindForPath(slice.slicePath ?? null, workspace);
|
||||
return kind ? <WorkspaceKindBadge kind={kind} compact /> : null;
|
||||
})()}
|
||||
{slice.railItem && (
|
||||
<span className="font-mono text-[8px] uppercase tracking-[0.10em] text-stone-500">{slice.railItem}</span>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
// to PL-005, which is out of scope here.
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspace } from "../../hooks/useWorkspace.js";
|
||||
import { WorkspaceKindBadge } from "../WorkspaceKindBadge.js";
|
||||
|
||||
interface MissionControlRow {
|
||||
qitemId: string;
|
||||
@@ -53,6 +55,7 @@ function isSpecRelated(row: MissionControlRow): boolean {
|
||||
}
|
||||
|
||||
export function InMotionPanel() {
|
||||
const workspace = useWorkspace();
|
||||
const view = useQuery({
|
||||
queryKey: ["mission-control", "views", "recently-active"],
|
||||
queryFn: fetchRecentlyActive,
|
||||
@@ -64,8 +67,15 @@ export function InMotionPanel() {
|
||||
className="border border-stone-300 bg-white"
|
||||
>
|
||||
<header className="flex items-baseline justify-between border-b border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
|
||||
Product-specs in motion
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
|
||||
Product-specs in motion
|
||||
</div>
|
||||
{/* PL-007: PRDs live in knowledge canon — surface the kind on
|
||||
* the panel header so the operator-context row is typed. */}
|
||||
{workspace.data?.knowledgeKind && (
|
||||
<WorkspaceKindBadge kind={workspace.data.knowledgeKind} compact />
|
||||
)}
|
||||
</div>
|
||||
<div className="font-mono text-[9px] text-stone-500">
|
||||
via Mission Control · recently-active filtered to spec-related
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { SessionPreviewPane } from "../preview/SessionPreviewPane.js";
|
||||
import { useWorkspace } from "../../hooks/useWorkspace.js";
|
||||
import { WorkspaceKindBadge } from "../WorkspaceKindBadge.js";
|
||||
|
||||
interface MissionControlRow {
|
||||
qitemId: string;
|
||||
@@ -34,6 +36,7 @@ export function LoopStatePanel() {
|
||||
// for that seat. Single-row-open at a time keeps the panel compact;
|
||||
// operators wanting multiple panes pin from the node-detail drawer.
|
||||
const [openSeat, setOpenSeat] = useState<string | null>(null);
|
||||
const workspace = useWorkspace();
|
||||
const view = useQuery({
|
||||
queryKey: ["mission-control", "views", "recently-active"],
|
||||
queryFn: fetchRecentlyActive,
|
||||
@@ -42,8 +45,13 @@ export function LoopStatePanel() {
|
||||
return (
|
||||
<section data-testid="steering-loop-state" className="border border-stone-300 bg-white">
|
||||
<header className="flex items-baseline justify-between border-b border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
|
||||
Loop state
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
|
||||
Loop state
|
||||
</div>
|
||||
{workspace.data?.knowledgeKind && (
|
||||
<WorkspaceKindBadge kind={workspace.data.knowledgeKind} compact />
|
||||
)}
|
||||
</div>
|
||||
<div className="font-mono text-[9px] text-stone-500">
|
||||
most-recent qitem per seat (cross-rig roll-up)
|
||||
|
||||
@@ -19,6 +19,9 @@ export interface SliceListEntry {
|
||||
qitemCount: number;
|
||||
hasProofPacket: boolean;
|
||||
lastActivityAt: string | null;
|
||||
/** PL-007: absolute filesystem path of the slice folder, used by the UI
|
||||
* to resolve workspace kind against the rig's RigSpec.workspace block. */
|
||||
slicePath?: string;
|
||||
}
|
||||
|
||||
export interface SliceListResponse {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// PL-007 Workspace Primitive v0 — UI hook for the typed workspace block.
|
||||
//
|
||||
// Surfaces /api/whoami's `workspace` field (when the rig declares one)
|
||||
// so consumer surfaces (Files, Progress, Slices, Specs, Steering) can
|
||||
// render workspace-kind labels without re-deriving topology.
|
||||
//
|
||||
// The workspace block is null when:
|
||||
// - the daemon has no workspace_json on the rigs row (legacy rig)
|
||||
// - the daemon predates PL-007 (cross-CLI-version drift; UI degrades
|
||||
// gracefully — surfaces simply skip the badge column)
|
||||
//
|
||||
// Defensive: every consumer must Array.isArray-guard `repos[]` before
|
||||
// iteration (founder rule for cross-CLI drift).
|
||||
//
|
||||
// Note: /api/whoami requires either nodeId or sessionName query param.
|
||||
// In a UI session that has no node identity (browser tab not bound to
|
||||
// a managed seat), the workspace block is unavailable; consumers should
|
||||
// treat null as "no badges", not as an error.
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { WorkspaceKindLabel } from "../components/WorkspaceKindBadge.js";
|
||||
|
||||
export interface WhoamiWorkspaceUI {
|
||||
workspaceRoot: string;
|
||||
activeRepo: string | null;
|
||||
repos: Array<{ name: string; path: string; kind: WorkspaceKindLabel }>;
|
||||
knowledgeRoot: string | null;
|
||||
knowledgeKind: WorkspaceKindLabel | null;
|
||||
}
|
||||
|
||||
interface WhoamiSnapshot {
|
||||
workspace?: WhoamiWorkspaceUI | null;
|
||||
}
|
||||
|
||||
async function fetchWorkspace(): Promise<WhoamiWorkspaceUI | null> {
|
||||
// Try sessionName from URL first (when the UI is opened with a seat
|
||||
// context query), else fall back to a generic any-session attempt that
|
||||
// returns null when ambiguous. v0: UI workspace is best-effort.
|
||||
const url = new URL(window.location.href);
|
||||
const sessionName = url.searchParams.get("session");
|
||||
const params = new URLSearchParams();
|
||||
if (sessionName) params.set("sessionName", sessionName);
|
||||
// No identifier → daemon returns 400; we degrade gracefully to null.
|
||||
if (params.toString() === "") return null;
|
||||
const res = await fetch(`/api/whoami?${params.toString()}`);
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json().catch(() => null) as WhoamiSnapshot | null;
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const ws = body.workspace;
|
||||
if (!ws || typeof ws !== "object") return null;
|
||||
// Defensive Array.isArray guard for cross-CLI drift
|
||||
if (!Array.isArray(ws.repos)) {
|
||||
return { ...ws, repos: [] };
|
||||
}
|
||||
return ws;
|
||||
}
|
||||
|
||||
export function useWorkspace() {
|
||||
return useQuery({
|
||||
queryKey: ["workspace", "whoami"],
|
||||
queryFn: fetchWorkspace,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// PL-007 Workspace Primitive v0 — WorkspaceKindBadge component test.
|
||||
//
|
||||
// Pins:
|
||||
// - renders one of 5 typed kinds with label + correct testid
|
||||
// - compact mode renders single-character glyph
|
||||
// - resolveKindForPath returns longest-prefix match
|
||||
// - resolveKindForPath returns "knowledge" when path is under
|
||||
// knowledgeRoot only
|
||||
// - resolveKindForPath returns null when path is outside everything
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { WorkspaceKindBadge, resolveKindForPath } from "../src/components/WorkspaceKindBadge.js";
|
||||
|
||||
describe("WorkspaceKindBadge (PL-007)", () => {
|
||||
it("renders the label for each of the 5 kinds", () => {
|
||||
for (const k of ["user", "project", "knowledge", "lab", "delivery"] as const) {
|
||||
const { unmount } = render(<WorkspaceKindBadge kind={k} />);
|
||||
expect(screen.getByTestId(`workspace-kind-badge-${k}`)).toBeTruthy();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("compact prop strips to glyph", () => {
|
||||
render(<WorkspaceKindBadge kind="knowledge" compact />);
|
||||
const el = screen.getByTestId("workspace-kind-badge-knowledge");
|
||||
expect(el.textContent).toBe("K");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKindForPath (PL-007)", () => {
|
||||
const workspace = {
|
||||
repos: [
|
||||
{ name: "main", path: "/r/hub/main", kind: "project" as const },
|
||||
{ name: "internal", path: "/r/hub/main/sub", kind: "project" as const },
|
||||
{ name: "lab", path: "/r/hub/lab", kind: "lab" as const },
|
||||
],
|
||||
knowledgeRoot: "/r/knowledge",
|
||||
};
|
||||
|
||||
it("longest-prefix wins", () => {
|
||||
expect(resolveKindForPath("/r/hub/main/sub/file.ts", workspace)).toBe("project");
|
||||
});
|
||||
|
||||
it("matches outer when not in nested", () => {
|
||||
expect(resolveKindForPath("/r/hub/main/other", workspace)).toBe("project");
|
||||
});
|
||||
|
||||
it("kind=knowledge when under knowledgeRoot only", () => {
|
||||
expect(resolveKindForPath("/r/knowledge/canon", workspace)).toBe("knowledge");
|
||||
});
|
||||
|
||||
it("returns null when outside everything", () => {
|
||||
expect(resolveKindForPath("/elsewhere", workspace)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when workspace is null", () => {
|
||||
expect(resolveKindForPath("/r/hub/main", null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when path is empty/null", () => {
|
||||
expect(resolveKindForPath(null, workspace)).toBeNull();
|
||||
expect(resolveKindForPath(undefined, workspace)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user