mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-10-02 08:35:15 +08:00
Keep project identity through TUI work navigation
This commit is contained in:
@@ -73,6 +73,29 @@ ownership receipt. A later seat start with no project-world input preserves it;
|
||||
an explicit install whose `install.skills` is empty clears it. This keeps
|
||||
"project not supplied" distinct from "project deliberately selects no skills."
|
||||
|
||||
## TUI project selection
|
||||
|
||||
Open **PROJECTS** (or enter `projects`) to choose an ID and root from the
|
||||
configured workspace catalog. `project <id>` selects an exact catalog entry;
|
||||
`mission <directory>` then opens that project's execution view. The historical
|
||||
`scopes` machine section ID remains valid. Project selection and its canonical
|
||||
root travel through mission, slice, source-file and Back navigation. Changing a
|
||||
catalog root requires selecting it again. Missing or malformed sources remain
|
||||
unavailable; another project's matching work ID is never a fallback.
|
||||
|
||||
`source` opens the current project, mission or slice's actual source through the
|
||||
existing file-reader allowlist. Catalog membership does not grant file-read or
|
||||
execution permission. These views only read; they do not select a lifecycle
|
||||
operation or activate a mission. Execution and slice queue membership require
|
||||
an exact `project:<id>` tag; lifecycle instances use their authored project
|
||||
identity. Unscoped historical queue rows and global review artifacts without
|
||||
project binding are excluded from project-specific claims.
|
||||
|
||||
The read API adds `GET /api/scopes/projects` and optional `project` and
|
||||
`projectRoot` parameters to scopes, execution and slice-detail reads. Slice
|
||||
detail also requires the selected mission directory. Existing reads without a
|
||||
project parameter keep their legacy contract.
|
||||
|
||||
## UI Mapping
|
||||
|
||||
- `workspace.root` maps to the Project workspace.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { selectCatalogProject, ProjectReadError } from "@openrig/daemon/project-catalog";
|
||||
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
@@ -217,41 +218,12 @@ export function resolveWorkPosition(opts: {
|
||||
const catalogPath = resolve(opts.catalogPath ?? join(workspaceRoot, "workspace.yaml"));
|
||||
let projectId: string | null = null;
|
||||
let projectRoot = workspaceRoot;
|
||||
if (existsSync(catalogPath)) {
|
||||
const parsed = readYaml(catalogPath);
|
||||
if (!parsed.value) return failure("workspace_catalog_invalid", parsed.error!);
|
||||
const rawProjects = parsed.value["projects"];
|
||||
if (!Array.isArray(rawProjects)) {
|
||||
return failure("workspace_catalog_invalid", `${catalogPath} must declare projects: [...]`);
|
||||
}
|
||||
const projects = rawProjects.map((entry) => {
|
||||
if (!isRecord(entry) || typeof entry["id"] !== "string" || typeof entry["root"] !== "string") return null;
|
||||
return { id: entry["id"], root: entry["root"] };
|
||||
});
|
||||
if (projects.some((entry) => entry === null)) {
|
||||
return failure("workspace_catalog_invalid", `${catalogPath} projects must each declare string id and root values`);
|
||||
}
|
||||
const declared = projects as Array<{ id: string; root: string }>;
|
||||
const candidates = declared.map((entry) => entry.id);
|
||||
const duplicateId = candidates.find((id, index) => candidates.indexOf(id) !== index);
|
||||
if (duplicateId) {
|
||||
return failure("project_identity_ambiguous", `project id '${duplicateId}' names multiple roots in ${catalogPath}`);
|
||||
}
|
||||
const selectedId = opts.project ?? (declared.length === 1 ? declared[0]!.id : undefined);
|
||||
if (!selectedId) {
|
||||
return failure("project_required", "multiple projects are declared; select one with --project", candidates);
|
||||
}
|
||||
const selected = declared.find((entry) => entry.id === selectedId);
|
||||
if (!selected) {
|
||||
return failure("project_not_found", `project '${selectedId}' is not declared in ${catalogPath}`, candidates);
|
||||
}
|
||||
const nominalRoot = isAbsolute(selected.root) ? resolve(selected.root) : resolve(dirname(catalogPath), selected.root);
|
||||
const canonicalRoot = canonicalExisting(nominalRoot);
|
||||
if (!canonicalRoot) {
|
||||
return failure("project_root_missing", `project '${selectedId}' root does not exist: ${nominalRoot}`);
|
||||
}
|
||||
projectId = selectedId;
|
||||
projectRoot = canonicalRoot;
|
||||
try {
|
||||
const selected = selectCatalogProject(catalogPath, opts.project);
|
||||
if (selected) { projectId = selected.id; projectRoot = selected.root; }
|
||||
} catch (err) {
|
||||
if (err instanceof ProjectReadError) return failure(err.code, err.message, err.candidates);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const projectManifestPath = join(projectRoot, "project.yaml");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./project-catalog": { "types": "./dist/project-catalog-surface.d.ts", "import": "./dist/project-catalog-surface.js" },
|
||||
"./daemon-shutdown": {
|
||||
"types": "./dist/daemon-shutdown.d.ts",
|
||||
"import": "./dist/daemon-shutdown.js"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { belongsToProject } from "./workspace/project-catalog.js";
|
||||
import { inspectGraph } from "./workflow-reconciliation.js";
|
||||
import { readMissionReadiness, readProjectReadiness } from "./proof/judgments.js";
|
||||
import { lifecycleObligations, requiredLifecycleSteps } from "./lifecycle-obligations.js";
|
||||
@@ -303,7 +304,7 @@ function lifecycleProjectAction(input: {
|
||||
* lifecycle_binding_json carries identity/provenance only. Older databases
|
||||
* have no migration-079 columns and therefore return an honest empty list.
|
||||
*/
|
||||
function readLifecycleExecutions(db: Database.Database, mission: string): Array<Record<string, unknown>> {
|
||||
function readLifecycleExecutions(db: Database.Database, mission: string, project?: string): Array<Record<string, unknown>> {
|
||||
if (!hasColumn(db, "workflow_instances", "lifecycle_binding_json")) return [];
|
||||
const rows = db.prepare(
|
||||
`SELECT wi.*, ws.spec_json
|
||||
@@ -320,7 +321,7 @@ function readLifecycleExecutions(db: Database.Database, mission: string): Array<
|
||||
for (const row of rows) {
|
||||
const binding = parseJsonRecord(row["lifecycle_binding_json"]);
|
||||
const identity = isRecord(binding["identity"]) ? binding["identity"] as Record<string, unknown> : {};
|
||||
if (identity["mission"] !== mission) continue;
|
||||
if (identity["mission"] !== mission || (project && identity["project"] !== project)) continue;
|
||||
const instanceId = String(row["instance_id"]);
|
||||
const frontier = parseJsonStringList(row["current_frontier_json"]);
|
||||
const specRoot = parseJsonRecord(row["spec_json"]);
|
||||
@@ -535,24 +536,25 @@ function readArrangement(missionsRoot: string, mission: string, slices: SliceFac
|
||||
}
|
||||
}
|
||||
|
||||
function readWaveMap(db: Database.Database, missions: string[]): WaveMapData {
|
||||
function readWaveMap(db: Database.Database, missions: string[], project?: string): WaveMapData {
|
||||
const missionWhere = missions.map(() => "tags LIKE ?").join(" OR ");
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT qitem_id, body FROM queue_items
|
||||
`SELECT qitem_id, body, tags FROM queue_items
|
||||
WHERE tags LIKE '%format:wave-map-v1%' AND (${missionWhere})
|
||||
ORDER BY ts_created DESC LIMIT 1`,
|
||||
ORDER BY ts_created DESC`,
|
||||
)
|
||||
.get(...missions.map((mission) => `%mission:${mission}%`)) as { qitem_id: string; body: string | null } | undefined;
|
||||
if (!row?.body) return { rowId: INDETERMINATE, waves: [] };
|
||||
const block = row.body.match(WAVE_MAP_BLOCK);
|
||||
.all(...missions.map((mission) => `%mission:${mission}%`)) as Array<{ qitem_id: string; body: string | null; tags: string | null }>;
|
||||
const selectedRow = row.find(r => !project || belongsToProject(r.tags, project));
|
||||
if (!selectedRow?.body) return { rowId: INDETERMINATE, waves: [] };
|
||||
const block = selectedRow.body.match(WAVE_MAP_BLOCK);
|
||||
if (!block) return { rowId: INDETERMINATE, waves: [] };
|
||||
try {
|
||||
const parsed = JSON.parse(block[1] ?? "");
|
||||
if (parsed?.format !== "wave-map-v1" || !Array.isArray(parsed.waves)) {
|
||||
return { rowId: INDETERMINATE, waves: [] };
|
||||
}
|
||||
return { rowId: row.qitem_id, waves: parsed.waves };
|
||||
return { rowId: selectedRow.qitem_id, waves: parsed.waves };
|
||||
} catch {
|
||||
return { rowId: INDETERMINATE, waves: [] };
|
||||
}
|
||||
@@ -631,7 +633,7 @@ function gitAncestor(exec: ExecutionViewDeps["exec"], repoCtx: string, sha: stri
|
||||
}
|
||||
}
|
||||
|
||||
export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: string; rig?: string }): Record<string, unknown> {
|
||||
export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: string; rig?: string; project?: string }): Record<string, unknown> {
|
||||
const now = deps.now ?? (() => new Date());
|
||||
const exec = deps.exec ?? defaultExec;
|
||||
const buildInfo = deps.buildInfo ?? BUILD_INFO;
|
||||
@@ -655,6 +657,7 @@ export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: s
|
||||
.prepare(`SELECT tags, body FROM queue_items WHERE state = 'in-progress' ORDER BY ts_updated DESC`)
|
||||
.all() as Array<{ tags: string | null; body: string | null }>;
|
||||
for (const row of active) {
|
||||
if (opts?.project && !belongsToProject(row.tags, opts.project)) continue;
|
||||
const tag = parseTags(row.tags).find((value) => value.startsWith("mission:"));
|
||||
// Canonical tags win; the conventional handoff line keeps older/body-only batons visible.
|
||||
const bodyMissions = [...new Set(
|
||||
@@ -687,7 +690,7 @@ export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: s
|
||||
? ["%"]
|
||||
: missionReferences(missionsRoot, mission).map((reference) => `%${reference}%`);
|
||||
const missionWhere = missionLikes.map(() => "(tags LIKE ? OR body LIKE ?)").join(" OR ");
|
||||
const rows = deps.db
|
||||
const candidateRows = deps.db
|
||||
.prepare(
|
||||
`SELECT qitem_id, source_session, destination_session, state, tags, body, claimed_at,
|
||||
last_heartbeat, blocked_on, ts_created, ts_updated,
|
||||
@@ -700,6 +703,8 @@ export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: s
|
||||
)
|
||||
.all(...missionLikes.flatMap((like) => [like, like])) as QueueRowLite[];
|
||||
|
||||
const rows = candidateRows.filter(row => !opts?.project || belongsToProject(row.tags, opts.project));
|
||||
|
||||
const sliceOfRow = (r: QueueRowLite): string | null => {
|
||||
for (const t of parseTags(r.tags)) {
|
||||
const m = t.match(SLICE_TAG);
|
||||
@@ -794,11 +799,11 @@ export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: s
|
||||
// ---- arrangement authority: mission/slice YAML, with legacy EC-2 fallback ----
|
||||
const waveMap = mission === INDETERMINATE
|
||||
? { rowId: INDETERMINATE as Indeterminate, waves: [] }
|
||||
: readWaveMap(deps.db, missionReferences(missionsRoot, mission));
|
||||
: readWaveMap(deps.db, missionReferences(missionsRoot, mission), opts?.project);
|
||||
const arrangement = missionsRoot && mission !== INDETERMINATE
|
||||
? readArrangement(missionsRoot, mission, slices)
|
||||
: null;
|
||||
const lifecycleExecutions = mission === INDETERMINATE ? [] : readLifecycleExecutions(deps.db, mission);
|
||||
const lifecycleExecutions = mission === INDETERMINATE ? [] : readLifecycleExecutions(deps.db, mission, opts?.project);
|
||||
const arrangementSlice = (id: string, dir?: string): ArrangementSlice | null => {
|
||||
if (arrangement?.state !== "valid") return null;
|
||||
return arrangement.byId.get(id) ?? (dir ? arrangement.byDir.get(dir) : undefined) ?? null;
|
||||
@@ -868,10 +873,10 @@ export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: s
|
||||
// tags, full shas, annotated fields) join by RESOLVED commit; malformed,
|
||||
// ambiguous, or non-resolving inputs are excluded with the reason carried —
|
||||
// they neither clear nor poison.
|
||||
const artifacts = scanReviewArtifacts(rigsRoot, [facts.dir, ...(typeof facts.id === "string" ? [facts.id] : [])]);
|
||||
const artifacts = opts?.project ? INDETERMINATE : scanReviewArtifacts(rigsRoot, [facts.dir, ...(typeof facts.id === "string" ? [facts.id] : [])]);
|
||||
let reviewed: Record<string, unknown>;
|
||||
if (artifacts === INDETERMINATE) {
|
||||
reviewed = { value: INDETERMINATE, basis: `review-artifact root unreadable (${rigsRoot})`, legs: [] };
|
||||
reviewed = { value: INDETERMINATE, basis: opts?.project ? "Global review artifacts have no project identity binding" : `review-artifact root unreadable (${rigsRoot})`, legs: [] };
|
||||
} else if (!candidateSha || !builtToken) {
|
||||
reviewed = { value: INDETERMINATE, basis: "no built candidate token to scope review legs to", legs: [] };
|
||||
} else if (!builtResolved) {
|
||||
@@ -1130,6 +1135,7 @@ export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: s
|
||||
};
|
||||
|
||||
return {
|
||||
...(opts?.project ? { project: opts.project, membership: "exact project:<id> queue tags and lifecycle identity; unscoped rows excluded" } : {}),
|
||||
view: "execution",
|
||||
readiness,
|
||||
project_readiness: missionsRoot ? readProjectReadiness(missionsRoot) : null,
|
||||
|
||||
@@ -223,6 +223,10 @@ export class SliceDetailProjector {
|
||||
this.workflowSpecCache = opts.workflowSpecCache;
|
||||
}
|
||||
|
||||
withIndexer(indexer: SliceIndexer): SliceDetailProjector {
|
||||
return new SliceDetailProjector({ db: this.db, indexer, workflowSpecCache: this.workflowSpecCache });
|
||||
}
|
||||
|
||||
project(slice: SliceRecord): SliceDetailPayload {
|
||||
// v1: resolve workflow_instance binding once; the bound spec drives
|
||||
// four downstream dimensions (story phase tags, spec graph, current
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { belongsToProject } from "../workspace/project-catalog.js";
|
||||
// Slice Story View v0 — slice indexer.
|
||||
//
|
||||
// Reads slice folders from a configured filesystem root. The default
|
||||
@@ -110,6 +111,8 @@ export interface SliceListEntry {
|
||||
}
|
||||
|
||||
export interface SliceIndexerOpts {
|
||||
projectId?: string;
|
||||
missionId?: string;
|
||||
/** Root directory containing slice folders. */
|
||||
slicesRoot: string;
|
||||
/** Additional compatible roots to scan after the primary root. */
|
||||
@@ -255,6 +258,8 @@ const STATUS_TO_BUCKET: Record<string, SliceStatus> = {
|
||||
};
|
||||
|
||||
export class SliceIndexer {
|
||||
readonly projectId?: string;
|
||||
readonly missionId?: string;
|
||||
readonly slicesRoot: string;
|
||||
readonly additionalSliceRoots: string[];
|
||||
readonly dogfoodEvidenceRoot: string | null;
|
||||
@@ -279,6 +284,8 @@ export class SliceIndexer {
|
||||
} | null = null;
|
||||
|
||||
constructor(opts: SliceIndexerOpts) {
|
||||
this.projectId = opts.projectId;
|
||||
this.missionId = opts.missionId;
|
||||
this.slicesRoot = opts.slicesRoot;
|
||||
this.additionalSliceRoots = opts.additionalSliceRoots ?? [];
|
||||
this.dogfoodEvidenceRoot = opts.dogfoodEvidenceRoot;
|
||||
@@ -510,7 +517,7 @@ export class SliceIndexer {
|
||||
});
|
||||
}
|
||||
}
|
||||
return locations.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return locations.filter(location => !this.missionId || location.missionId === this.missionId).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private findSliceLocation(name: string): SliceLocation | null {
|
||||
@@ -860,6 +867,7 @@ export class SliceIndexer {
|
||||
`SELECT qitem_id, tags FROM queue_items WHERE tags LIKE '%slice:%' ORDER BY ts_created DESC, qitem_id DESC`,
|
||||
);
|
||||
for (const r of stmt.iterate() as Iterable<{ qitem_id: string; tags: string | null }>) {
|
||||
if (this.projectId && !belongsToProject(r.tags, this.projectId)) continue;
|
||||
for (const tagged of parseScopeTags(r.tags).slices) {
|
||||
const bucket = typedBySlice.get(tagged);
|
||||
if (!bucket) typedBySlice.set(tagged, [r.qitem_id]);
|
||||
@@ -931,6 +939,7 @@ export class SliceIndexer {
|
||||
? this.db.prepare(`SELECT qitem_id, body, tags FROM queue_items`)
|
||||
: this.db.prepare(`SELECT qitem_id, body FROM queue_items`);
|
||||
for (const r of scan.iterate() as Iterable<{ qitem_id: string; body: string; tags?: string | null }>) {
|
||||
if (this.projectId && !belongsToProject(r.tags, this.projectId)) continue;
|
||||
const hayBody = asciiFold(sqliteVisiblePrefix(r.body));
|
||||
const hayTags = r.tags != null ? asciiFold(sqliteVisiblePrefix(r.tags)) : null;
|
||||
if (!prefilter.test(hayBody) && !(hayTags !== null && prefilter.test(hayTags))) continue;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProjectRead } from "./workspace/project-read.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { lastMeaningfulTransition } from "./queue-waiting.js";
|
||||
import { derivePickup } from "./queue-pickup.js";
|
||||
@@ -116,7 +117,7 @@ export class ViewProjector {
|
||||
* Run a view by name. Built-in names (BUILT_IN_VIEW_NAMES) dispatch to
|
||||
* hardcoded SQL; other names dispatch to custom-view lookup.
|
||||
*/
|
||||
show(viewName: string, opts?: { rig?: string; limit?: number; mission?: string }): ViewQueryResult {
|
||||
show(viewName: string, opts?: { rig?: string; limit?: number; mission?: string; project?: ProjectRead | null }): ViewQueryResult {
|
||||
const limit = Math.max(1, Math.min(opts?.limit ?? 100, 1000));
|
||||
// S27 — the execution view is document-shaped (rows = [one JSON document])
|
||||
// and derives from fs/git/build-info legs beyond this class's SQL, so it
|
||||
@@ -128,7 +129,7 @@ export class ViewProjector {
|
||||
"execution view deps are not wired on this daemon (setExecutionDeps was never called)",
|
||||
);
|
||||
}
|
||||
const doc = buildExecutionView(this.executionDeps, { mission: opts?.mission, rig: opts?.rig });
|
||||
const doc = buildExecutionView(opts?.project ? { ...this.executionDeps, slicesRoot: () => opts.project!.missionsRoot } : this.executionDeps, { mission: opts?.mission, rig: opts?.rig, project: opts?.project?.id });
|
||||
return { viewName: "execution", generatedAt: this.now().toISOString(), rows: [doc], rowCount: 1 };
|
||||
}
|
||||
if ((BUILT_IN_VIEW_NAMES as readonly string[]).includes(viewName)) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { parse } from "yaml";
|
||||
|
||||
export class ProjectReadError extends Error {
|
||||
constructor(readonly code: string, message: string, readonly candidates?: string[]) { super(message); }
|
||||
}
|
||||
export function yamlObject(file: string): Record<string, any> {
|
||||
try {
|
||||
const value = parse(fs.readFileSync(file, "utf8"));
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected a YAML object");
|
||||
return value;
|
||||
} catch (err) { throw new ProjectReadError("workspace_catalog_invalid", `${file}: ${(err as Error).message}`); }
|
||||
}
|
||||
/** The existing workspace.yaml catalog, shared by work-install and project reads. */
|
||||
export function readProjectCatalog(catalogPath: string): Array<{ id: string; root: string }> | null {
|
||||
if (!fs.existsSync(catalogPath)) return null;
|
||||
const entries = yamlObject(catalogPath).projects;
|
||||
if (!Array.isArray(entries) || entries.some(e => !e || typeof e.id !== "string" || typeof e.root !== "string"))
|
||||
throw new ProjectReadError("workspace_catalog_invalid", `${catalogPath} projects must each declare string id and root values`);
|
||||
const ids = entries.map(e => e.id);
|
||||
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
|
||||
if (duplicate) throw new ProjectReadError("project_identity_ambiguous", `project id '${duplicate}' names multiple roots in ${catalogPath}`);
|
||||
return entries.map(e => ({ id: e.id, root: e.root }));
|
||||
}
|
||||
export function selectCatalogProject(catalogPath: string, selectedId?: string): { id: string; root: string } | null {
|
||||
const projects = readProjectCatalog(catalogPath);
|
||||
if (!projects) return null;
|
||||
const candidates = projects.map(p => p.id);
|
||||
const id = selectedId ?? (projects.length === 1 ? projects[0]!.id : undefined);
|
||||
if (!id) throw new ProjectReadError("project_required", "multiple projects are declared; select one with --project", candidates);
|
||||
const selected = projects.find(p => p.id === id);
|
||||
if (!selected) throw new ProjectReadError("project_not_found", `project '${id}' is not declared in ${catalogPath}`, candidates);
|
||||
const nominal = path.resolve(path.dirname(catalogPath), selected.root);
|
||||
try { return { id, root: fs.realpathSync(nominal) }; }
|
||||
catch { throw new ProjectReadError("project_root_missing", `project '${id}' root does not exist: ${nominal}`); }
|
||||
}
|
||||
/** Exact project membership; unscoped historical rows are not assigned to a selected project. */
|
||||
export function belongsToProject(raw: string | null | undefined, id: string): boolean {
|
||||
try {
|
||||
const tags: unknown = JSON.parse(raw ?? "null");
|
||||
return Array.isArray(tags) && tags.flatMap(t => typeof t === "string" ? t.split(",").map(s => s.trim()) : []).includes(`project:${id}`);
|
||||
} catch { return false; }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { parse } from "yaml";
|
||||
import type { SettingsStore } from "../user-settings/settings-store.js";
|
||||
import { NODE_FILE_PRECEDENCE } from "../scope/node-file.js";
|
||||
import { readProjectCatalog, selectCatalogProject, yamlObject, ProjectReadError } from "./project-catalog.js";
|
||||
|
||||
export interface ProjectRead { id: string; root: string; name: string; sourcePath: string | null; missionsRoot: string; error?: string }
|
||||
type ReadContext = { get: (key: never) => unknown; req: { query: (key: string) => string | undefined } };
|
||||
export function projectCatalogPaths(c: Pick<ReadContext, "get">) {
|
||||
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
|
||||
const workspace = store?.resolveOne("workspace.root").value;
|
||||
if (typeof workspace !== "string" || !workspace) throw new ProjectReadError("workspace_root_missing", "Workspace root is not configured");
|
||||
const configured = store?.resolveOne("workspace.catalog_path").value;
|
||||
return { workspace, catalog: typeof configured === "string" && configured ? configured : path.join(workspace, "workspace.yaml") };
|
||||
}
|
||||
export function insideProject(root: string, target: string): string {
|
||||
const actual = fs.realpathSync(target);
|
||||
const rel = path.relative(root, actual);
|
||||
if (path.isAbsolute(rel) || rel === ".." || rel.startsWith(`..${path.sep}`)) throw new ProjectReadError("project_path_escape", `Source is outside selected project: ${target}`);
|
||||
return actual;
|
||||
}
|
||||
export function workSource(root: string, dir: string): string {
|
||||
insideProject(root, dir);
|
||||
const source = NODE_FILE_PRECEDENCE.map(name => path.join(dir, name)).find(file => fs.existsSync(file));
|
||||
if (!source) throw new ProjectReadError("source_unavailable", `No work source at ${dir}`);
|
||||
insideProject(root, source);
|
||||
const text = fs.readFileSync(source, "utf8");
|
||||
if (text.startsWith("---")) {
|
||||
const end = text.indexOf("\n---", 3);
|
||||
if (end < 0) throw new ProjectReadError("source_invalid", `Unterminated frontmatter: ${source}`);
|
||||
const value = parse(text.slice(3, end));
|
||||
if (value !== null && (typeof value !== "object" || Array.isArray(value))) throw new ProjectReadError("source_invalid", `Invalid frontmatter: ${source}`);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
function projectEntry(id: string, root: string): ProjectRead {
|
||||
let p: ProjectRead = { id, root, name: id, sourcePath: null, missionsRoot: path.join(root, "missions") };
|
||||
try {
|
||||
p.root = fs.realpathSync(root);
|
||||
const manifest = path.join(p.root, "project.yaml");
|
||||
const value = fs.existsSync(manifest) ? yamlObject(insideProject(p.root, manifest)) : {};
|
||||
const declared = value.id ?? value.metadata?.id;
|
||||
if (declared && declared !== id) throw new ProjectReadError("project_identity_conflict", `Catalog ${id} conflicts with project identity ${declared}`);
|
||||
p.name = typeof value.metadata?.name === "string" ? value.metadata.name : id;
|
||||
const missions = value.missions?.root ?? "missions";
|
||||
if (typeof missions !== "string" || path.isAbsolute(missions) || missions.split(/[\\/]/).includes("..")) throw new ProjectReadError("missions_root_escape", "Invalid project missions.root");
|
||||
p.missionsRoot = path.resolve(p.root, missions);
|
||||
p.sourcePath = workSource(p.root, p.root);
|
||||
if (fs.existsSync(p.missionsRoot)) insideProject(p.root, p.missionsRoot);
|
||||
} catch (err) { p.error = (err as Error).message; }
|
||||
return p;
|
||||
}
|
||||
export function listProjects(c: Pick<ReadContext, "get">): { catalogPath: string; projects: ProjectRead[] } {
|
||||
const { workspace, catalog } = projectCatalogPaths(c);
|
||||
const entries = readProjectCatalog(catalog);
|
||||
if (entries) return { catalogPath: catalog, projects: entries.map(e => projectEntry(e.id, path.resolve(path.dirname(catalog), e.root))) };
|
||||
// Uncatalogued workspaces keep their existing single-project authority.
|
||||
const manifest = path.join(workspace, "project.yaml");
|
||||
const value = fs.existsSync(manifest) ? yamlObject(manifest) : {};
|
||||
return { catalogPath: catalog, projects: [projectEntry(value.id ?? value.metadata?.id ?? "workspace", workspace)] };
|
||||
}
|
||||
export function selectedProject(c: ReadContext): ProjectRead | null {
|
||||
const id = c.req.query("project");
|
||||
if (id === undefined) return null;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id)) throw new ProjectReadError("invalid_project", "Invalid project ID");
|
||||
const { catalog } = projectCatalogPaths(c);
|
||||
const selected = selectCatalogProject(catalog, id);
|
||||
const p = selected ? projectEntry(selected.id, selected.root) : listProjects(c).projects.find(p => p.id === id);
|
||||
if (!p) throw new ProjectReadError("project_not_found", `Project ${id} is unavailable`);
|
||||
if (c.req.query("projectRoot") && c.req.query("projectRoot") !== p.root) throw new ProjectReadError("project_changed", `Project ${id} root changed; choose it again`);
|
||||
if (p.error) throw new ProjectReadError("project_unavailable", `${id}: ${p.error}`);
|
||||
if (!fs.existsSync(p.missionsRoot)) throw new ProjectReadError("missions_unavailable", `${id}: missions root is unavailable: ${p.missionsRoot}`);
|
||||
return p;
|
||||
}
|
||||
export function projectReadResponse(err: unknown): Response {
|
||||
return Response.json({ error: err instanceof ProjectReadError ? err.code : "project_source_unavailable", message: (err as Error).message }, { status: 409 });
|
||||
}
|
||||
export function projectMission(p: ProjectRead, mission: string): string {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(mission)) throw new ProjectReadError("invalid_mission", "Invalid mission directory");
|
||||
const dir = path.join(p.missionsRoot, mission);
|
||||
workSource(p.root, dir);
|
||||
const slices = path.join(dir, "slices");
|
||||
if (fs.existsSync(slices)) {
|
||||
insideProject(p.root, slices);
|
||||
for (const child of fs.readdirSync(slices, { withFileTypes: true }))
|
||||
if (child.isDirectory() || child.isSymbolicLink()) workSource(p.root, path.join(slices, child.name));
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { readProjectCatalog, selectCatalogProject, ProjectReadError } from "./domain/workspace/project-catalog.js";
|
||||
@@ -1,3 +1,4 @@
|
||||
import { listProjects, selectedProject, projectReadResponse, projectMission, workSource, insideProject } from "../domain/workspace/project-read.js";
|
||||
// SCOPES VIEW (sealed plan d64d2f5c) — the store-direct read routes behind the scopes TUI.
|
||||
// GET /api/scopes?mission=X -> mission slice summaries (cards/counts/locks)
|
||||
// GET /api/scopes/slice?mission=&slice= -> the full detail (intent/mini-reqs/contract+drops)
|
||||
@@ -19,7 +20,9 @@ const realFs: ScopeFsDeps = {
|
||||
isDirectory: (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } },
|
||||
};
|
||||
|
||||
function rootOf(c: { get: (k: never) => unknown }): { root: string } | { error: Response } {
|
||||
function rootOf(c: Parameters<typeof selectedProject>[0]): { root: string } | { error: Response } {
|
||||
const selected = selectedProject(c);
|
||||
if (selected) return { root: selected.missionsRoot };
|
||||
const indexer = c.get("sliceIndexer" as never) as SliceIndexer | undefined;
|
||||
if (!indexer) return { error: Response.json({ error: "slices_indexer_unavailable" }, { status: 503 }) };
|
||||
if (!indexer.isReady()) return { error: Response.json({ error: "slices_root_not_configured" }, { status: 503 }) };
|
||||
@@ -28,19 +31,24 @@ function rootOf(c: { get: (k: never) => unknown }): { root: string } | { error:
|
||||
|
||||
export function scopesRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
app.get("/projects", c => { try { return c.json(listProjects(c)); } catch (err) { return projectReadResponse(err); } });
|
||||
app.onError(err => projectReadResponse(err));
|
||||
|
||||
app.get("/", (c) => {
|
||||
const r = rootOf(c);
|
||||
if ("error" in r) return r.error;
|
||||
const selected = selectedProject(c);
|
||||
const mission = c.req.query("mission");
|
||||
if (selected && mission) projectMission(selected, mission);
|
||||
const wantDetail = c.req.query("detail") === "1";
|
||||
const detailFor = (missionName: string, dirName: string): (SliceScopeDetail & { narrative: string | null }) | null => {
|
||||
if (selected) workSource(selected.root, path.join(r.root, missionName, "slices", dirName));
|
||||
const d = projectSliceScope(realFs, path.join(r.root, missionName, "slices", dirName));
|
||||
if (!d) return null;
|
||||
// The TUI one-read hydrate: narrative CONTENT rides inline for the `n` DISPLAY —
|
||||
// still never a data source (the projection never reads it for counts).
|
||||
const narrative = d.progressPath ? realFs.readFile(d.progressPath) : null;
|
||||
return { ...d, narrative };
|
||||
return { ...d, narrative, ...(selected ? { sourcePath: workSource(selected.root, path.join(r.root, missionName, "slices", dirName)) } : {}) };
|
||||
};
|
||||
if (mission) {
|
||||
const m = projectMissionScopes(realFs, r.root, mission);
|
||||
@@ -51,19 +59,33 @@ export function scopesRoutes(): Hono {
|
||||
}
|
||||
// No mission param: list every mission (the explorer tree); ?detail=1 upgrades rows to details.
|
||||
const missionNames = realFs.listDir(r.root).filter((e) => realFs.isDirectory(path.join(r.root, e)));
|
||||
const missions = missionNames
|
||||
const readErrors: string[] = [];
|
||||
const sources: Record<string, string> = {};
|
||||
const visibleMissions = missionNames.filter(name => {
|
||||
if (!selected) return true;
|
||||
try {
|
||||
const dir = projectMission(selected, name);
|
||||
sources[name] = workSource(selected.root, dir);
|
||||
return true;
|
||||
} catch (err) { readErrors.push(`${name}: ${(err as Error).message}`); return false; }
|
||||
});
|
||||
const missions = visibleMissions
|
||||
.map((e) => projectMissionScopes(realFs, r.root, e))
|
||||
.filter((m): m is NonNullable<typeof m> => m !== null)
|
||||
.map((m) => ({ ...(wantDetail ? { mission: m.mission, slices: m.slices.map((sl) => detailFor(m.mission, sl.dirName)).filter(Boolean) } : m), readiness: readMissionReadiness(path.join(r.root, m.mission)) }));
|
||||
return c.json({ missions, sourceObservation: proofSourceObservation(c) });
|
||||
return c.json({ missions, sources, readErrors, project: selected, sourceObservation: proofSourceObservation(c) });
|
||||
});
|
||||
|
||||
app.get("/slice", (c) => {
|
||||
const r = rootOf(c);
|
||||
if ("error" in r) return r.error;
|
||||
const selected = selectedProject(c);
|
||||
const mission = c.req.query("mission");
|
||||
if (selected && mission) projectMission(selected, mission);
|
||||
const slice = c.req.query("slice");
|
||||
if (selected && slice && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(slice)) return c.json({ error: "invalid_slice" }, 400);
|
||||
if (!mission || !slice) return c.json({ error: "missing_params", hint: "?mission=&slice=" }, 400);
|
||||
if (selected && slice) workSource(selected.root, path.join(r.root, mission!, "slices", slice));
|
||||
const detail = projectSliceScope(realFs, path.join(r.root, mission, "slices", slice));
|
||||
return detail ? c.json(detail) : c.json({ error: "slice_not_found", mission, slice }, 404);
|
||||
});
|
||||
@@ -71,10 +93,14 @@ export function scopesRoutes(): Hono {
|
||||
app.get("/narrative", (c) => {
|
||||
const r = rootOf(c);
|
||||
if ("error" in r) return r.error;
|
||||
const selected = selectedProject(c);
|
||||
const mission = c.req.query("mission");
|
||||
if (selected && mission) projectMission(selected, mission);
|
||||
const slice = c.req.query("slice");
|
||||
if (selected && slice && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(slice)) return c.json({ error: "invalid_slice" }, 400);
|
||||
if (!mission || !slice) return c.json({ error: "missing_params", hint: "?mission=&slice=" }, 400);
|
||||
const p = path.join(r.root, mission, "slices", slice, "PROGRESS.md");
|
||||
if (selected) insideProject(selected.root, p);
|
||||
const content = realFs.readFile(p);
|
||||
// The narrative DISPLAY (plan atom 3): raw artifact bytes; explicitly not a data source.
|
||||
return content !== null ? c.json({ path: p, content }) : c.json({ error: "narrative_not_found" }, 404);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { selectedProject, projectMission, workSource, projectReadResponse } from "../domain/workspace/project-read.js";
|
||||
// Slice Story View v0 — HTTP routes.
|
||||
//
|
||||
// Endpoints:
|
||||
@@ -18,8 +19,8 @@ import { Hono } from "hono";
|
||||
import { readSliceReadiness, readProjectReadiness } from "../domain/proof/judgments.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { SliceIndexer, SliceListEntry, SliceStatus } from "../domain/slices/slice-indexer.js";
|
||||
import type { SliceDetailProjector } from "../domain/slices/slice-detail-projector.js";
|
||||
import { SliceIndexer, SliceListEntry, SliceStatus } from "../domain/slices/slice-indexer.js";
|
||||
import { SliceDetailProjector } from "../domain/slices/slice-detail-projector.js";
|
||||
import { findSliceWorkflowBinding } from "../domain/workflow/slice-workflow-binding.js";
|
||||
|
||||
export interface SlicesRoutesDeps {
|
||||
@@ -173,9 +174,20 @@ export function slicesRoutes(): Hono {
|
||||
|
||||
// 4) Dynamic `/:name` LAST so the literal routes above are not shadowed.
|
||||
app.get("/:name", (c) => {
|
||||
const deps = getDeps(c);
|
||||
let deps = getDeps(c);
|
||||
if (!deps) return c.json({ error: "slices_indexer_unavailable" }, 503);
|
||||
const name = c.req.param("name");
|
||||
try {
|
||||
const project = selectedProject(c);
|
||||
if (project) {
|
||||
const mission = c.req.query("mission");
|
||||
if (!mission || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)) return c.json({ error: "exact_mission_and_slice_required" }, 400);
|
||||
const dir = projectMission(project, mission);
|
||||
workSource(project.root, path.join(dir, "slices", name));
|
||||
const indexer = new SliceIndexer({ db: deps.indexer.db, slicesRoot: project.missionsRoot, dogfoodEvidenceRoot: null, projectId: project.id, missionId: mission });
|
||||
deps = { indexer, projector: deps.projector.withIndexer(indexer) };
|
||||
}
|
||||
} catch (err) { return projectReadResponse(err); }
|
||||
const slice = deps.indexer.get(name);
|
||||
if (!slice) return c.json({ error: "slice_not_found", name }, 404);
|
||||
const payload = deps.projector.project(slice);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { selectedProject, projectMission, projectReadResponse } from "../domain/workspace/project-read.js";
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
@@ -116,7 +117,12 @@ export function viewsRoutes(): Hono {
|
||||
// in the module; --mission widens/narrows).
|
||||
const mission = c.req.query("mission") || undefined;
|
||||
try {
|
||||
const result = getProjector(c).show(viewName, { rig, limit, mission });
|
||||
let project;
|
||||
try {
|
||||
project = viewName === "execution" ? selectedProject(c) : null;
|
||||
if (project && mission) projectMission(project, mission);
|
||||
} catch (err) { return projectReadResponse(err); }
|
||||
const result = getProjector(c).show(viewName, { rig, limit, mission, project });
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } 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 { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { ViewProjector } from "../src/domain/view-projector.js";
|
||||
import { SliceIndexer } from "../src/domain/slices/slice-indexer.js";
|
||||
import { SliceDetailProjector } from "../src/domain/slices/slice-detail-projector.js";
|
||||
import { scopesRoutes } from "../src/routes/scopes.js";
|
||||
import { viewsRoutes } from "../src/routes/views.js";
|
||||
import { slicesRoutes } from "../src/routes/slices.js";
|
||||
import { selectCatalogProject } from "../src/domain/workspace/project-catalog.js";
|
||||
let root: string, app: Hono, db: ReturnType<typeof createDb>;
|
||||
function file(name: string, text: string) { fs.mkdirSync(path.dirname(name), { recursive: true }); fs.writeFileSync(name, text); }
|
||||
function source(text: string) { return `---\nid: same-id\nstatus: active\n---\n# Same name\n\n## Intent\n\n${text}\n`; }
|
||||
beforeEach(() => {
|
||||
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "s03-projects-")));
|
||||
file(path.join(root, "workspace.yaml"), "projects:\n - id: a\n root: a\n - id: b\n root: b\n");
|
||||
for (const id of ["a", "b"]) {
|
||||
file(path.join(root, id, "project.yaml"), `metadata:\n id: ${id}\n name: Same project\n`);
|
||||
file(path.join(root, id, "SPEC.md"), source(`${id} project only`));
|
||||
file(path.join(root, id, "missions/release-x/SPEC.md"), source(`${id} mission only`));
|
||||
file(path.join(root, id, "missions/release-x/slices/01-story/SPEC.md"), source(`${id} slice only`));
|
||||
}
|
||||
db = createDb(); migrate(db, ALL_MIGRATIONS);
|
||||
for (const id of ["a", "b", "unscoped"]) db.prepare("INSERT INTO queue_items(qitem_id,ts_created,ts_updated,source_session,destination_session,state,priority,tier,tags,body) VALUES(?,?,?,?,?,'in-progress','normal','light',?,?)")
|
||||
.run(`qitem-${id}`, "2026-09-10T00:00:00Z", "2026-09-10T00:00:00Z", "root@test", `${id}@test`, JSON.stringify(["mission:release-x", "slice:same-id", ...(id === "unscoped" ? [] : [`project:${id}`])]), `${id} work`);
|
||||
const projector = new ViewProjector(db, new EventBus(db));
|
||||
projector.setExecutionDeps({ db, slicesRoot: () => path.join(root, "b/missions") });
|
||||
const indexer = new SliceIndexer({ db, slicesRoot: path.join(root, "b/missions"), dogfoodEvidenceRoot: null });
|
||||
const detail = new SliceDetailProjector({ db, indexer });
|
||||
app = new Hono();
|
||||
app.use("*", async(c, next) => {
|
||||
c.set("settingsStore" as never, { resolveOne: (key: string) => ({ value: key === "workspace.root" ? root : path.join(root, "workspace.yaml") }) });
|
||||
c.set("viewProjector" as never, projector); c.set("sliceIndexer" as never, indexer); c.set("sliceDetailProjector" as never, detail);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/scopes", scopesRoutes()); app.route("/api/views", viewsRoutes()); app.route("/api/slices", slicesRoutes());
|
||||
});
|
||||
afterEach(() => { db.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
async function get(url: string) { const response = await app.request(url); return { status: response.status, body: await response.json() as any }; }
|
||||
describe("catalog project identity across work reads", () => {
|
||||
it("shares CLI catalog selection, preserves equal work IDs and filters execution/queue membership", async () => {
|
||||
const catalog = await get("/api/scopes/projects"); expect(catalog.body.projects.map((p: any) => p.id)).toEqual(["a", "b"]);
|
||||
for (const id of ["a", "b"]) {
|
||||
expect(selectCatalogProject(path.join(root, "workspace.yaml"), id)?.root).toBe(path.join(root, id));
|
||||
const scopes = await get(`/api/scopes?detail=1&project=${id}`);
|
||||
expect(scopes.body.missions[0].slices[0].intent).toBe(`${id} slice only`);
|
||||
const execution = await get(`/api/views/execution?mission=release-x&project=${id}`);
|
||||
expect(execution.status).toBe(200);
|
||||
expect(JSON.stringify(execution.body)).toContain(`qitem-${id}`);
|
||||
expect(JSON.stringify(execution.body)).not.toContain(`qitem-${id === "a" ? "b" : "a"}`);
|
||||
expect(JSON.stringify(execution.body)).not.toContain("qitem-unscoped");
|
||||
const detail = await get(`/api/slices/01-story?mission=release-x&project=${id}`);
|
||||
expect(detail.status).toBe(200); expect(detail.body.qitemIds).toEqual([`qitem-${id}`]);
|
||||
}
|
||||
});
|
||||
it("reports removed, changed, malformed and escaping sources without substituting another project", async () => {
|
||||
fs.renameSync(path.join(root, "a/SPEC.md"), path.join(root, "a/SPEC.retained.md"));
|
||||
let scopes = await get("/api/scopes?detail=1&project=a"); expect(scopes.status).toBe(409); expect(scopes.body.error).toBe("project_unavailable");
|
||||
expect(JSON.stringify(scopes.body)).not.toContain("b slice only");
|
||||
expect((await get("/api/scopes?detail=1&project=b")).status).toBe(200);
|
||||
file(path.join(root, "a/SPEC.md"), source("a restored"));
|
||||
expect((await get("/api/scopes?project=a&projectRoot=wrong-root")).body.error).toBe("project_changed");
|
||||
file(path.join(root, "a/missions/release-x/slices/01-story/SPEC.md"), "---\nid: [broken\n---\n");
|
||||
scopes = await get("/api/scopes?detail=1&project=a"); expect(scopes.body.missions).toEqual([]); expect(scopes.body.readErrors.length).toBe(1);
|
||||
expect((await get("/api/views/execution?project=a&mission=../b")).status).toBe(409);
|
||||
fs.renameSync(path.join(root, "a/missions"), path.join(root, "a/missions-retained"));
|
||||
fs.symlinkSync(path.join(root, "b/missions"), path.join(root, "a/missions"));
|
||||
expect((await get("/api/scopes?project=a")).status).toBe(409);
|
||||
file(path.join(root, "workspace.yaml"), "projects: [broken");
|
||||
expect((await get("/api/scopes/projects")).status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,9 @@ export const COMMAND_REGISTRY: readonly CommandEntry[] = [
|
||||
{ name: "recent", aliases: [], args: "<transition-id>", description: "inspect an original event from the served Recent window", context: "standard", sample: "recent 1", complete: ({ snapshot }) => (snapshot.recentTransitions ?? []).map((r) => String(r.transitionId)), build: (id) => /^\d+$/.test(id) && Number.isSafeInteger(Number(id)) ? { type: "recent-open", transitionId: Number(id) } : { type: "error", message: "recent needs a transition id from the served window" } },
|
||||
{ name: "connections", aliases: [], args: "", description: "legacy gateway/routes view; use CONFIG for instance settings", context: "standard", sample: "connections", build: () => ({ type: "jump", section: "connections" }) },
|
||||
{ name: "back", aliases: [], args: "", description: "return to the previous view, selection and scroll", context: "standard", sample: "back", build: () => ({ type: "back" }) },
|
||||
{ name: "projects", aliases: [], args: "", description: "choose a project from the workspace catalog", context: "standard", sample: "projects", build: () => ({ type: "jump", section: "scopes" }) },
|
||||
{ name: "project", aliases: [], args: "<id>", description: "select an exact catalog project", context: "standard", sample: "project example", complete: ({ snapshot }) => (snapshot.projects?.projects ?? []).map(p => p.id), build: id => id ? { type: "project-select", id } : { type: "error", message: "project needs a catalog ID" } },
|
||||
{ name: "source", aliases: [], args: "", description: "read the selected project, mission or slice source", context: "standard", sample: "source", build: () => ({ type: "project-source" }) },
|
||||
{ name: "mission", aliases: [], args: "<name>", description: "open a mission's work and workflows", context: "standard", sample: "mission release-demo", complete: ({ snapshot }) => (snapshot.scopes ?? []).map((m) => m.mission), build: (name) => name ? { type: "scopes-mission-open", mission: name } : { type: "error", message: "mission needs a name" } },
|
||||
{ name: "workflow", aliases: [], args: "<instance-id>", description: "open a workflow in the selected mission", context: "standard", sample: "workflow example", complete: (ctx) => workflowArgs(ctx, false), build: (name) => name ? { type: "execution-open", key: `workflow:${name}` } : { type: "error", message: "workflow needs an instance id" } },
|
||||
{ name: "packet", aliases: [], args: "<qitem-id>", description: "open current workflow work in the selected mission", context: "standard", sample: "packet example", complete: (ctx) => workflowArgs(ctx, true), build: (name) => name ? { type: "execution-open", key: `packet:${name}` } : { type: "error", message: "packet needs a queue id" } },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProjectSelection } from "./types.js";
|
||||
// The TUI's ONLY daemon surface — thin typed fetch wrappers over the §4.A
|
||||
// endpoint table, ONE module by design: the R7 no-new-data source-check is a
|
||||
// one-file read (every route below is an EXISTING, web-consumed daemon read).
|
||||
@@ -191,17 +192,23 @@ export class DaemonClient {
|
||||
|
||||
// --- Needs-You (§4.A rows 5–6): composeNeedsYou legs + host/rig-down beside ---
|
||||
/** SCOPES view (d64d2f5c): the store-direct one-read hydrate. */
|
||||
scopesDetailed() {
|
||||
return this.get("/api/scopes?detail=1");
|
||||
projects() { return this.get("/api/scopes/projects"); }
|
||||
private projectQuery(project?: ProjectSelection | null): string {
|
||||
return project ? `&project=${encodeURIComponent(project.id)}&projectRoot=${encodeURIComponent(project.root)}` : "";
|
||||
}
|
||||
scopesDetailed(project?: ProjectSelection | null) {
|
||||
return this.get(`/api/scopes?detail=1${this.projectQuery(project)}`);
|
||||
}
|
||||
|
||||
/** EXECUTION view via the shipped generic view route; no new endpoint. */
|
||||
execution(mission?: string) {
|
||||
return this.get(`/api/views/execution${mission ? `?mission=${encodeURIComponent(mission)}` : ""}`);
|
||||
execution(mission?: string, project?: ProjectSelection | null) {
|
||||
const query = `${mission ? `&mission=${encodeURIComponent(mission)}` : ""}${this.projectQuery(project)}`;
|
||||
return this.get(`/api/views/execution${query ? "?" + query.slice(1) : ""}`);
|
||||
}
|
||||
/** Existing six-tab slice payload; read only for the opened slice. */
|
||||
sliceDetail(directory: string) {
|
||||
return this.get(`/api/slices/${encodeURIComponent(directory)}`);
|
||||
sliceDetail(directory: string, mission?: string | null, project?: ProjectSelection | null) {
|
||||
const query = `${mission ? `&mission=${encodeURIComponent(mission)}` : ""}${this.projectQuery(project)}`;
|
||||
return this.get(`/api/slices/${encodeURIComponent(directory)}${query ? "?" + query.slice(1) : ""}`);
|
||||
}
|
||||
|
||||
/** One bounded typed queue chronology for the current topology scope. */
|
||||
|
||||
@@ -344,7 +344,7 @@ function agentSpecTruth(raw?: string): { runtime?: string; skills: string[] } {
|
||||
* avoids re-reading every spec every refresh; the key rolls when the library
|
||||
* entry's updatedAt changes. Owned by the caller (instance-scoped, no module state). */
|
||||
export type SpecReviewCache = Map<string, SpecLibraryReviewRead>;
|
||||
export type HydrateViewContext = Pick<ViewState, "section" | "viewTab" | "drill" | "file" | "externalUrl">;
|
||||
export type HydrateViewContext = Pick<ViewState, "project" | "section" | "viewTab" | "drill" | "file" | "externalUrl">;
|
||||
|
||||
export async function hydrateSnapshot(
|
||||
client: DaemonClient,
|
||||
@@ -375,6 +375,23 @@ export async function hydrateSnapshot(
|
||||
}
|
||||
if (viewContext?.externalUrl) return { ...emptySnapshot(), hydratedAt: new Date().toISOString() };
|
||||
|
||||
// Project reads never fall back to the daemon's default workspace or fleet queue.
|
||||
if (viewContext?.section === "scopes") {
|
||||
const projects = await safe<NonNullable<FleetSnapshot["projects"]>>("projects", () => client.projects());
|
||||
const selected = viewContext.project;
|
||||
const project = selected && projects?.projects.find(p => p.id === selected.id && p.root === selected.root);
|
||||
if (selected && (!project || project.error)) readErrors.push(`project ${selected.id}: ${project?.error ?? "selection changed or unavailable; choose the project again"}`);
|
||||
const readable = !!project && !project.error;
|
||||
const [scopes, execution, detail, roots] = await Promise.all([
|
||||
readable ? safe<{ missions: FleetSnapshot["scopes"]; sources?: Record<string, string>; readErrors?: string[] }>("scopes", () => client.scopesDetailed(selected)) : null,
|
||||
readable && executionMission ? safe<{ rows: NonNullable<FleetSnapshot["execution"]>[] }>("execution", () => client.execution(executionMission, selected)) : null,
|
||||
readable && executionMission && sliceDetailName ? safe<SliceDetailSnap>("slice-detail", () => client.sliceDetail(sliceDetailName, executionMission, selected)) : null,
|
||||
safe<Awaited<ReturnType<DaemonClient["fileRoots"]>>>("file-roots", () => client.fileRoots()),
|
||||
]);
|
||||
readErrors.push(...(scopes?.readErrors ?? []));
|
||||
return { ...emptySnapshot(), projects, projectRead: selected, projectSources: scopes?.sources, scopes: scopes?.missions ?? [], execution: execution?.rows[0] ?? null, executionMission, sliceDetail: detail, sliceDetailName, fileRoots: roots?.roots ?? [], readErrors, hydratedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
// CONFIG never invokes fleet aggregation, host probes, queue enrichment or provider checks.
|
||||
// Failures replace earlier values with an explicit unavailable state.
|
||||
if (viewContext?.section === "config") {
|
||||
|
||||
+24
-19
@@ -683,7 +683,7 @@ function instanceContentLines(
|
||||
function contentLines(state: ViewState, snap: FleetSnapshot, contentWidth: number, motion: MotionCtx): ContentLine[] {
|
||||
if (state.file) {
|
||||
const read = JSON.stringify(snap.fileRead?.target) === JSON.stringify(state.file) ? snap.fileRead?.result : null;
|
||||
return fileLines(read, state.file, contentWidth);
|
||||
return [...(state.project ? wrapDetailLines([{ text: `Project ${state.project.id} · ${state.project.root}` }], contentWidth) : []), ...fileLines(read, state.file, contentWidth)];
|
||||
}
|
||||
if (state.externalUrl) return externalLines(state.externalUrl, contentWidth);
|
||||
const contentWidthForGraph = contentWidth;
|
||||
@@ -1111,16 +1111,21 @@ function contentLines(state: ViewState, snap: FleetSnapshot, contentWidth: numbe
|
||||
return lines;
|
||||
}
|
||||
if (state.section === "scopes") {
|
||||
if (!state.scopesMission && snap.execution) {
|
||||
const selected = snap.execution;
|
||||
const instances = selected.lifecycle_instances;
|
||||
return wrapDetailLines([
|
||||
{ text: "SCOPES · choose a mission in the explorer" },
|
||||
listItem(`Default mission · ${selected.mission}`, { type: "scopes-mission-open", mission: selected.mission }),
|
||||
{ text: ` Workflows: ${instances ? instances.map((instance) => String(instance.status ?? "unknown")).join(", ") || "none bound" : "projection unavailable"}` },
|
||||
{ text: ` Execution snapshot: ${displayTime(selected.derived_at, state.timeZone)}. Other missions load when selected.` },
|
||||
], contentWidth);
|
||||
}
|
||||
const catalog = snap.projects;
|
||||
if (!state.project && catalog !== undefined) return wrapDetailLines([
|
||||
{ text: "PROJECTS · select a project" },
|
||||
{ text: catalog ? `Catalog: ${catalog.catalogPath}` : "Project catalog unavailable or loading" },
|
||||
...(snap.readErrors ?? []).map(text => ({ text })),
|
||||
...(catalog?.projects ?? []).flatMap(p => [listItem(`${p.name} · ${p.id}`, { type: "project-select", id: p.id }), { text: p.root }, ...(p.error ? [{ text: `Unavailable: ${p.error}` }] : [])]),
|
||||
...(catalog?.projects.length === 0 ? [{ text: "No projects declared in this catalog." }] : []),
|
||||
], contentWidth);
|
||||
const identity = state.project ? wrapDetailLines([{ text: `PROJECT ${state.project.id}` }, { text: state.project.root }], contentWidth) : [];
|
||||
if (state.project && (snap.projectRead?.id !== state.project.id || snap.projectRead?.root !== state.project.root)) return [...identity, { text: "Reading selected project…" }];
|
||||
const entry = catalog?.projects.find(p => p.id === state.project!.id && p.root === state.project!.root);
|
||||
const errors = (snap.readErrors ?? []).map(text => ({ text: `Unavailable: ${text}` }));
|
||||
const projectHeader = state.project ? [...identity, listItem("Read current source", { type: "project-source" }), ...wrapDetailLines(errors, contentWidth)] : [];
|
||||
if (state.project && (!entry || entry.error)) return [...projectHeader, { text: "Choose a project again or go Back." }];
|
||||
if (state.project && !state.scopesMission) return [...projectHeader, { text: "Choose a mission" }, ...(snap.scopes ?? []).map(m => listItem(m.mission, { type: "scopes-mission-open", mission: m.mission })), ...(!snap.scopes?.length && !errors.length ? [{ text: "No missions found in this project." }] : [])];
|
||||
// SCOPES owns both levels. Both mission-graph and Explorer slice routes land
|
||||
// on the same execution-backed canonical detail; store-direct content is
|
||||
// composed into that page instead of surviving as a competing destination.
|
||||
@@ -1131,27 +1136,27 @@ function contentLines(state: ViewState, snap: FleetSnapshot, contentWidth: numbe
|
||||
? (snap.scopes ?? []).find((m) => m.mission === sel.mission)?.slices.find((sl) => sl.dirName === sel.slice) ?? null
|
||||
: null;
|
||||
if (state.executionOpen && execution) {
|
||||
return executionContentLines(execution, snap.scopes, snap.readErrors, state.executionOpen, contentWidth, false, snap.sliceDetail, {
|
||||
return [...projectHeader, ...executionContentLines(execution, snap.scopes, snap.readErrors, state.executionOpen, contentWidth, false, snap.sliceDetail, {
|
||||
collapseReqs: state.scopesCollapseReqs,
|
||||
narrative: state.scopesNarrative,
|
||||
}, state.timeZone);
|
||||
}, state.timeZone)];
|
||||
}
|
||||
if (detail && execution) {
|
||||
return executionContentLines(execution, snap.scopes, snap.readErrors, `slice:${detail.id ?? detail.dirName}`, contentWidth, false, snap.sliceDetail, {
|
||||
return [...projectHeader, ...executionContentLines(execution, snap.scopes, snap.readErrors, `slice:${detail.id ?? detail.dirName}`, contentWidth, false, snap.sliceDetail, {
|
||||
collapseReqs: state.scopesCollapseReqs,
|
||||
narrative: state.scopesNarrative,
|
||||
}, state.timeZone);
|
||||
}, state.timeZone)];
|
||||
}
|
||||
if (!detail && missionName) {
|
||||
const lines = executionContentLines(execution, snap.scopes, snap.readErrors, state.executionOpen, contentWidth, !snap.hydratedAt || snap.executionMission !== missionName, undefined, undefined, state.timeZone);
|
||||
return execution ? lines : [{ text: ` ${missionName} EXECUTION` }, ...lines];
|
||||
return [...projectHeader, ...(execution ? lines : [{ text: ` ${missionName} EXECUTION` }, ...lines])];
|
||||
}
|
||||
return scopesContentLines(detail, missionName, {
|
||||
return [...projectHeader, ...scopesContentLines(detail, missionName, {
|
||||
collapseReqs: state.scopesCollapseReqs,
|
||||
narrative: state.scopesNarrative,
|
||||
width: contentWidth,
|
||||
executionStrip: detail ? executionSliceStripLines(null, detail.id ?? detail.dirName, detail.dirName, contentWidth, detail.status) : undefined,
|
||||
});
|
||||
})];
|
||||
}
|
||||
return [{ text: `(${state.section})` }];
|
||||
}
|
||||
@@ -1663,7 +1668,7 @@ export function renderScreen(state: ViewState, snap: FleetSnapshot, options: Ren
|
||||
lines.push(pad(`${mark} ${label}${alias} ${tail}`, cols));
|
||||
}
|
||||
}
|
||||
const sectionTitle = { topology: "TOPOLOGY", specs: "SPECS", needs: "NEEDS-YOU" }[state.section] ?? state.section.toUpperCase();
|
||||
const sectionTitle = { topology: "TOPOLOGY", specs: "SPECS", scopes: "PROJECTS", needs: "NEEDS-YOU" }[state.section] ?? state.section.toUpperCase();
|
||||
// active-pane emphasis (k9s-class chrome): the focused pane's title is bracketed
|
||||
const explorerTitle = state.focusedPane === "explorer" ? "{ EXPLORER }" : "EXPLORER";
|
||||
const contentTitle = state.focusedPane === "content" ? `{ ${sectionTitle} }` : sectionTitle;
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface ReadinessSnap {
|
||||
} | null }>;
|
||||
}
|
||||
export interface SliceScopeSnap {
|
||||
sourcePath?: string;
|
||||
readiness?: ReadinessSnap;
|
||||
dirName: string;
|
||||
id: string | null;
|
||||
|
||||
@@ -14,8 +14,8 @@ export const SECTION_REGISTRY: readonly SectionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "scopes",
|
||||
sourceRead: "GET /api/scopes?detail=1 + /api/views/execution?mission= (existing projections)",
|
||||
drillShape: "mission>execution-row>slice/source",
|
||||
sourceRead: "GET /api/scopes/projects + project-scoped scopes, execution and slice projections",
|
||||
drillShape: "project>mission>execution-row>slice/source",
|
||||
},
|
||||
{ name: "config", sourceRead: "GET /healthz + /api/config?view=browser + /api/gateway/connections (passive)", drillShape: "category>setting" },
|
||||
{ name: "connections", sourceRead: "GET /healthz + /api/gateway/connections (passive projection)", drillShape: "instance>human/routes>work" },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileTargetForPath } from "./reading.js";
|
||||
import { CONFIG_CATEGORIES } from "./config/config-model.js";
|
||||
import { availableTabs } from "./commands/registry.js";
|
||||
import { DEFAULT_TIME_ZONE, resolveTimeZone } from "./time.js";
|
||||
@@ -73,6 +74,7 @@ export function createViewState(options: CreateViewStateOptions): ViewStateStore
|
||||
notice: null,
|
||||
lastError: null,
|
||||
palette: null,
|
||||
project: null,
|
||||
scopesMission: null,
|
||||
scopesSelected: null,
|
||||
scopesCollapseReqs: false,
|
||||
@@ -84,7 +86,7 @@ export function createViewState(options: CreateViewStateOptions): ViewStateStore
|
||||
|
||||
function dispatch(action: Action): ViewState {
|
||||
const previous = state;
|
||||
if (["jump", "drill", "cross", "tab", "scopes-mission-open", "scopes-open", "health-open", "execution-open", "recent-open", "timezone", "config-category", "config-setting"].includes(action.type)) state = { ...state, file: null, externalUrl: null, recentOpen: null, timeZoneHelp: false };
|
||||
if (["project-select", "jump", "drill", "cross", "tab", "scopes-mission-open", "scopes-open", "health-open", "execution-open", "recent-open", "timezone", "config-category", "config-setting"].includes(action.type)) state = { ...state, file: null, externalUrl: null, recentOpen: null, timeZoneHelp: false };
|
||||
state = reduce(state, action, getSnapshot());
|
||||
// Connections is a side trip from work, including explorer/palette entry.
|
||||
if (action.type === "jump" && !["connections", "config"].includes(action.section) && !["connections", "config"].includes(previous.section)) state.history = [];
|
||||
@@ -140,6 +142,7 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
|
||||
return resetContent({ ...next, section: "config", drill: [], viewTab: "table", configKey: action.key, healthOpen: null });
|
||||
case "jump": {
|
||||
// scopes: jumping anywhere (incl. back to :scopes) closes the opened slice.
|
||||
if (action.section === "scopes") next.project = null;
|
||||
next.scopesMission = null;
|
||||
next.scopesSelected = null;
|
||||
next.executionOpen = null;
|
||||
@@ -153,7 +156,22 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
|
||||
snap,
|
||||
);
|
||||
}
|
||||
case "project-select": {
|
||||
const project = snap.projects?.projects.find(p => p.id === action.id);
|
||||
if (!project) return { ...next, lastError: `Project ${action.id} is not in the current catalog` };
|
||||
return syncSelection(resetContent({ ...next, section: "scopes", project: { id: project.id, root: project.root }, drill: [], scopesMission: null, scopesSelected: null, executionOpen: null, scopesNarrative: false, filter: "", expanded: [], viewTab: "table" }), snap);
|
||||
}
|
||||
case "project-source": {
|
||||
if (!state.project || snap.projectRead?.id !== state.project.id || snap.projectRead?.root !== state.project.root) return { ...next, lastError: "Selected project read is pending" };
|
||||
const entry = snap.projects?.projects.find(p => p.id === state.project!.id && p.root === state.project!.root);
|
||||
const missionSource = state.scopesMission ? snap.projectSources?.[state.scopesMission] : null;
|
||||
const sliceDir = state.scopesSelected?.slice ?? snap.sliceDetailName;
|
||||
const source = sliceDir && state.scopesMission ? snap.scopes?.find(m => m.mission === state.scopesMission)?.slices.find(s => s.dirName === sliceDir)?.sourcePath : missionSource ?? entry?.sourcePath;
|
||||
if (!source) return { ...next, lastError: "Selected source is unavailable" };
|
||||
return reduce(next, { type: "file-open", target: fileTargetForPath(source, snap.fileRoots ?? []) ?? { root: "", path: source } }, snap);
|
||||
}
|
||||
case "scopes-mission-open": {
|
||||
if (snap.projects !== undefined && !state.project) return { ...next, lastError: "Choose a project first" };
|
||||
const key = `scopes-mission:${action.mission}`;
|
||||
const expanded = state.expanded.includes(key) ? state.expanded : [...state.expanded, key];
|
||||
return syncSelection(resetContent({ ...next, section: "scopes", drill: [], runningOf: null, viewTab: "table", filter: "", scopesMission: action.mission, scopesSelected: null, executionOpen: null, healthOpen: null, expanded }), snap);
|
||||
@@ -276,12 +294,12 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
|
||||
}
|
||||
|
||||
function location(s: ViewState): string {
|
||||
return JSON.stringify([s.section, s.drill, s.runningOf, s.scopesMission, s.scopesSelected, s.executionOpen, s.recentOpen?.transitionId, s.timeZoneHelp, s.configCategory, s.configKey, s.file, s.externalUrl]);
|
||||
return JSON.stringify([s.project, s.section, s.drill, s.runningOf, s.scopesMission, s.scopesSelected, s.executionOpen, s.recentOpen?.transitionId, s.timeZoneHelp, s.configCategory, s.configKey, s.file, s.externalUrl]);
|
||||
}
|
||||
|
||||
function navigationFrame(s: ViewState): NavigationFrame {
|
||||
const { file, externalUrl, section, drill, filter, selection, runningOf, viewTab, contentOffset, contentMaxOffset, contentTargetCount, contentSelection, focusedPane, scopesMission, scopesSelected, scopesCollapseReqs, scopesNarrative, executionOpen, expanded, recentOpen, timeZoneHelp, configCategory, configKey } = s;
|
||||
return { file, externalUrl, section, drill, filter, selection, runningOf, viewTab, contentOffset, contentMaxOffset, contentTargetCount, contentSelection, focusedPane, scopesMission, scopesSelected, scopesCollapseReqs, scopesNarrative, executionOpen, expanded, recentOpen, timeZoneHelp, configCategory, configKey };
|
||||
const { project, file, externalUrl, section, drill, filter, selection, runningOf, viewTab, contentOffset, contentMaxOffset, contentTargetCount, contentSelection, focusedPane, scopesMission, scopesSelected, scopesCollapseReqs, scopesNarrative, executionOpen, expanded, recentOpen, timeZoneHelp, configCategory, configKey } = s;
|
||||
return { project, file, externalUrl, section, drill, filter, selection, runningOf, viewTab, contentOffset, contentMaxOffset, contentTargetCount, contentSelection, focusedPane, scopesMission, scopesSelected, scopesCollapseReqs, scopesNarrative, executionOpen, expanded, recentOpen, timeZoneHelp, configCategory, configKey };
|
||||
}
|
||||
|
||||
function clearScopeCoordinatesOnSectionChange(previous: ViewState, next: ViewState): ViewState {
|
||||
@@ -529,7 +547,7 @@ export function computeExplorerRows(state: ViewState, snap: FleetSnapshot): Expl
|
||||
? "SPECS"
|
||||
: section.name === "needs"
|
||||
? "NEEDS-YOU"
|
||||
: section.name.toUpperCase();
|
||||
: section.name === "scopes" ? "PROJECTS" : section.name.toUpperCase();
|
||||
// A section changes view but has no independent collapse state. Do not draw
|
||||
// a disclosure glyph that cannot be toggled.
|
||||
rows.push({ label, action: { type: "jump", section: section.name }, key: `section:${section.name}` });
|
||||
@@ -540,8 +558,13 @@ export function computeExplorerRows(state: ViewState, snap: FleetSnapshot): Expl
|
||||
continue;
|
||||
}
|
||||
if (section.name === "scopes") {
|
||||
const expanded = new Set(state.expanded);
|
||||
rows.push(...scopesExplorerRows(snap.scopes, expanded, " "));
|
||||
if (snap.projects === undefined) rows.push(...scopesExplorerRows(snap.scopes, new Set(state.expanded), " "));
|
||||
for (const project of snap.projects?.projects ?? []) {
|
||||
rows.push({ label: ` ${state.project?.id === project.id ? "●" : "○"} ${project.id}${project.error ? " !" : ""}`, key: `project:${project.id}`, action: { type: "project-select", id: project.id } });
|
||||
if (state.project?.id === project.id && state.project.root === project.root && snap.projectRead?.id === project.id && snap.projectRead.root === project.root)
|
||||
rows.push(...scopesExplorerRows(snap.scopes, new Set(state.expanded), " "));
|
||||
}
|
||||
if (state.history?.length) rows.push({ label: " Back", key: "project:back", action: { type: "back" } });
|
||||
continue;
|
||||
}
|
||||
if (section.name === "topology") {
|
||||
|
||||
@@ -324,7 +324,13 @@ export interface HealthSnapshot {
|
||||
records: HealthRecord[];
|
||||
}
|
||||
|
||||
export interface ProjectSelection { id: string; root: string }
|
||||
export interface ProjectEntry extends ProjectSelection { name: string; sourcePath: string | null; missionsRoot: string; error?: string }
|
||||
export interface FleetSnapshot {
|
||||
projects?: { catalogPath: string; projects: ProjectEntry[] } | null;
|
||||
projectRead?: ProjectSelection | null;
|
||||
projectSources?: Record<string, string>;
|
||||
|
||||
fileRead?: { target: import("./reading.js").FileTarget; result: import("./reading.js").FileReadResult; readAt: string };
|
||||
fileRoots?: import("./reading.js").FileRoot[];
|
||||
specsLoaded?: boolean;
|
||||
@@ -438,6 +444,8 @@ export type Action =
|
||||
| { type: "style"; name: string }
|
||||
/** REGISTRY I3 — the command palette (open/query/move/close ride dispatch like all state). */
|
||||
/** SCOPES view: m collapse + n narrative toggles (dispatch-riding). */
|
||||
| { type: "project-select"; id: string }
|
||||
| { type: "project-source" }
|
||||
| { type: "scopes-mission-open"; mission: string }
|
||||
| { type: "scopes-open"; mission: string; slice: string }
|
||||
| { type: "scopes-reqs" }
|
||||
@@ -479,6 +487,7 @@ export interface ViewState {
|
||||
/** Canonical health finding opened from any instance/rig/seat surface. */
|
||||
healthOpen: string | null;
|
||||
/** SCOPES view: the mission whose execution story is open (null = selector only). */
|
||||
project?: ProjectSelection | null;
|
||||
scopesMission: string | null;
|
||||
/** SCOPES view: the opened slice (null = tree only). */
|
||||
scopesSelected: { mission: string; slice: string } | null;
|
||||
@@ -515,7 +524,7 @@ export interface ViewState {
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export type NavigationFrame = Pick<ViewState, "file" | "externalUrl" | "section" | "drill" | "filter" | "selection" | "runningOf" | "viewTab" | "contentOffset" | "contentMaxOffset" | "contentTargetCount" | "contentSelection" | "focusedPane" | "scopesMission" | "scopesSelected" | "scopesCollapseReqs" | "scopesNarrative" | "executionOpen" | "expanded" | "timeZoneHelp" | "recentOpen" | "configCategory" | "configKey">;
|
||||
export type NavigationFrame = Pick<ViewState, "project" | "file" | "externalUrl" | "section" | "drill" | "filter" | "selection" | "runningOf" | "viewTab" | "contentOffset" | "contentMaxOffset" | "contentTargetCount" | "contentSelection" | "focusedPane" | "scopesMission" | "scopesSelected" | "scopesCollapseReqs" | "scopesNarrative" | "executionOpen" | "expanded" | "timeZoneHelp" | "recentOpen" | "configCategory" | "configKey">;
|
||||
|
||||
export interface ViewStateStore {
|
||||
instanceId: string;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { DaemonClient } from "../src/daemon-client.js";
|
||||
import { hydrateSnapshot } from "../src/hydrate.js";
|
||||
import { createViewState, emptySnapshot } from "../src/state.js";
|
||||
import { renderScreen } from "../src/render.js";
|
||||
import { parseCommand } from "../src/grammar.js";
|
||||
import { demoSnapshot } from "../src/demo-data.js";
|
||||
import type { FleetSnapshot } from "../src/types.js";
|
||||
|
||||
it.each([[140, 42], [80, 24]])("retains exact project through equal-ID work, file, Back and in-flight switching at %ix%i", async (cols, rows) => {
|
||||
const requests: string[] = [];
|
||||
let unavailable = false;
|
||||
const projects = ["a", "b"].map(id => ({ id, name: "Book", root: `/books/${id}`, sourcePath: `/books/${id}/SPEC.md`, missionsRoot: `/books/${id}/missions` }));
|
||||
const client = new DaemonClient({ baseUrl: "http://fixture", fetchImpl: (async (url, init) => {
|
||||
const u = new URL(String(url)); requests.push(`${init?.method ?? "GET"} ${u.pathname}${u.search}`);
|
||||
const id = u.searchParams.get("project");
|
||||
let body: unknown;
|
||||
if (u.pathname === "/api/scopes/projects") body = { catalogPath: "/books/workspace.yaml", projects: projects.map(p => ({ ...p, ...(unavailable && p.id === "a" ? { error: "SPEC unavailable" } : {}) })) };
|
||||
else if (u.pathname === "/api/files/roots") body = { roots: projects.map(p => ({ name: p.id, path: p.root })) };
|
||||
else if (u.pathname === "/api/scopes") body = { missions: [{ mission: "release-x", slices: [{ ...demoSnapshot().scopes![0]!.slices[0]!, dirName: "01-story", id: "same-id", intent: `${id} unique intent`, sourcePath: `/books/${id}/missions/release-x/slices/01-story/SPEC.md` }] }], sources: { "release-x": `/books/${id}/missions/release-x/SPEC.md` } };
|
||||
else if (u.pathname === "/api/views/execution") body = { rows: [{ view: "execution", mission: "release-x", sources: {}, q1_lanes: [], q2_sequencing: [], q4_ladder: [], q5_park: [] }] };
|
||||
else if (u.pathname.startsWith("/api/slices")) body = null;
|
||||
else if (u.pathname === "/api/files/read") body = { root: u.searchParams.get("root"), path: u.searchParams.get("path"), absolutePath: `/books/${u.searchParams.get("root")}/SPEC.md`, content: "Authored project source", mtime: "2026-09-10T00:00:00Z", contentHash: "abc", size: 10, truncated: false, truncatedAtBytes: null, totalBytes: 10 };
|
||||
else throw new Error(`Unexpected read ${u.pathname}`);
|
||||
return Response.json(body);
|
||||
}) as typeof fetch });
|
||||
let snap: FleetSnapshot = emptySnapshot();
|
||||
const view = createViewState({ instanceId: "projects", getSnapshot: () => snap });
|
||||
async function refresh() { snap = await hydrateSnapshot(client, undefined, view.get().scopesMission, view.get().scopesSelected?.slice, null, view.get()); }
|
||||
const text = () => renderScreen(view.get(), snap, { cols, rows }).lines.join("\n");
|
||||
view.dispatch(parseCommand("projects")); await refresh();
|
||||
expect(text()).toContain("PROJECTS"); expect(text()).toContain("Book · a"); expect(text()).toContain("Book · b");
|
||||
view.dispatch(parseCommand("project a")); await refresh();
|
||||
view.dispatch(parseCommand("mission release-x")); await refresh();
|
||||
view.dispatch({ type: "scopes-open", mission: "release-x", slice: "01-story" }); await refresh();
|
||||
const caller = view.get();
|
||||
view.dispatch(parseCommand("source")); expect(view.get().file?.path).toContain("01-story/SPEC.md"); await refresh();
|
||||
expect(text()).toContain("Project a");
|
||||
view.dispatch({ type: "back" }); await refresh(); expect(view.get().project).toEqual(caller.project); expect(view.get().scopesSelected).toEqual(caller.scopesSelected);
|
||||
view.dispatch(parseCommand("project b"));
|
||||
expect(text()).toContain("Reading selected project"); expect(text()).not.toContain("a unique intent");
|
||||
await refresh(); expect(text()).toContain("PROJECT b"); expect(view.get().scopesMission).toBeNull();
|
||||
view.dispatch({ type: "back" }); await refresh(); expect(view.get().project?.id).toBe("a"); expect(view.get().scopesSelected?.slice).toBe("01-story");
|
||||
unavailable = true; await refresh(); expect(text()).toContain("SPEC unavailable"); expect(text()).not.toContain("a unique intent");
|
||||
expect(requests.every(r => r.startsWith("GET "))).toBe(true);
|
||||
for (const request of requests.filter(r => /GET \/api\/(scopes\?|views\/execution|slices\/)/.test(r))) {
|
||||
expect(request).toMatch(/project=[ab]/); expect(request).toMatch(/projectRoot=%2Fbooks%2F[ab]/);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user