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:
mvschwarzandClaude Opus 4.7 committed 2026-05-04 14:40:46 -07:00
1 parent a9637fd1f3
commit f9051590cb
36 files changed
+1961 -72

No files matched your search

+12
View File
@@ -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()}`);
+127
View File
@@ -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}`);
}
}
+3
View File
@@ -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);
`,
};
+24 -1
View File
@@ -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;
+100 -48
View File
@@ -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,
};
}
+45
View File
@@ -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);
}
+56 -1
View File
@@ -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);
});
+66
View File
@@ -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;
}
+2
View File
@@ -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());
+3 -1
View File
@@ -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();
});
});
+25
View File
@@ -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)
+3
View File
@@ -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 {
+64
View File
@@ -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();
});
});