flow-store.mjs
1,203 lines 36.4 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 11 days ago
1 /**
2 * Local file-backed Flow store (Flow v0 — Phase 7A-10b, Option A calendar parity).
3 *
4 * Persists flow definitions, steps, runs, candidates, and projections per vault under
5 * data_dir. Read-only list/get in v0; idempotent starter seed on first read.
6 *
7 * @see docs/FLOW-STORE-CONTRACT-7A-10.md
8 * @see docs/FLOW-V0-SPEC.md
9 */
10
11 import fs from 'fs';
12 import path from 'path';
13 import { randomUUID } from 'crypto';
14 import { getRepoRoot } from '../repo-root.mjs';
15
16 export const FLOW_STORE_FILENAME = 'hub_flow_store.json';
17 export const STARTER_FLOWS_DIRNAME = 'flows/starter';
18 export const MAX_FLOW_SUMMARIES = 200;
19 export const MAX_STEPS_PER_FLOW = 100;
20
21 export const FLOW_ID_RE = /^flow_[a-z0-9_]{1,64}$/;
22 export const FLOW_STEP_ID_RE = /^flow_[a-z0-9_]{1,64}#[1-9][0-9]*$/;
23 export const FLOW_RUN_ID_RE = /^run_[a-z0-9_]{1,48}$/;
24 /** Portable cross-system pointer (Scooling runRef / overseer lineage). */
25 export const FLOW_RUN_REF_RE = /^flow_run:[A-Za-z0-9._:-]{1,128}$/;
26 export const FLOW_CANDIDATE_ID_RE = /^cand_[a-z0-9]{4,32}$/;
27 export const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
28
29 export const FLOW_RUN_SCHEMA = 'knowtation.flow_run/v0';
30 export const FLOW_RUN_LIST_SCHEMA = 'knowtation.flow_run_list/v0';
31 export const FLOW_RUN_GET_SCHEMA = 'knowtation.flow_run_get/v0';
32
33 /** Canonical overseer loopback pointer (9A-3 / P-FLOW seed). */
34 export const OVERSEER_FIXTURE_RUN_REF = 'flow_run:fixture-overseer-001';
35 export const MAX_FLOW_RUNS_LIST = 200;
36
37 /** @typedef {'personal'|'project'|'org'} FlowScope */
38
39 /**
40 * @typedef {Object} StoredFlow
41 * @property {'knowtation.flow/v0'} schema
42 * @property {string} flow_id
43 * @property {string} title
44 * @property {string} version
45 * @property {FlowScope} scope
46 * @property {string} summary
47 * @property {string[]} [tags]
48 * @property {string[]} steps
49 * @property {{ name: string, type: string, required: boolean }[]} [inputs]
50 * @property {string|null} [vault_mirror_path]
51 * @property {string} updated
52 * @property {boolean} truncated
53 */
54
55 /**
56 * @typedef {Object} StoredFlowStep
57 * @property {'knowtation.flow_step/v0'} schema
58 * @property {string} step_id
59 * @property {string} flow_id
60 * @property {string} [flow_version] - store-internal parent semver (7A-10c); omitted on wire
61 * @property {number} ordinal
62 * @property {string} owned_job
63 * @property {string} instruction
64 * @property {string} trigger
65 * @property {string} when_not_to_run
66 * @property {{ kind: string, id: string }[]} [requires]
67 * @property {string[]} boundaries
68 * @property {{ kind: string, id: string }[]} [skill_refs]
69 * @property {{ name: string, from: string }[]} [inputs]
70 * @property {{ name: string, type: string }[]} [outputs]
71 * @property {string} output_shape
72 * @property {{ kind: string, evidence_required: boolean, description: string }} verification
73 * @property {'manual'|'agent_assisted'|'automatable'} automatable
74 */
75
76 /**
77 * @typedef {Object} VaultFlowStore
78 * @property {StoredFlow[]} flows
79 * @property {StoredFlowStep[]} steps
80 * @property {object[]} runs
81 * @property {object[]} candidates
82 * @property {object[]} projections
83 * @property {object[]} tasks
84 * @property {object[]} task_loops
85 * @property {object[]} orchestrator_graphs
86 */
87
88 /**
89 * @typedef {Object} FlowStoreFile
90 * @property {Record<string, VaultFlowStore>} vaults
91 */
92
93 /**
94 * @param {string} dataDir
95 * @returns {string}
96 */
97 export function getFlowStorePath(dataDir) {
98 return path.join(dataDir, FLOW_STORE_FILENAME);
99 }
100
101 /**
102 * @param {string} dataDir
103 * @returns {FlowStoreFile}
104 */
105 export function loadFlowStore(dataDir) {
106 const filePath = getFlowStorePath(dataDir);
107 if (!fs.existsSync(filePath)) {
108 return { vaults: {} };
109 }
110 try {
111 const raw = fs.readFileSync(filePath, 'utf8');
112 const parsed = JSON.parse(raw);
113 if (!parsed || typeof parsed !== 'object' || !parsed.vaults || typeof parsed.vaults !== 'object') {
114 return { vaults: {} };
115 }
116 return /** @type {FlowStoreFile} */ (parsed);
117 } catch {
118 return { vaults: {} };
119 }
120 }
121
122 /**
123 * @param {string} dataDir
124 * @param {FlowStoreFile} store
125 */
126 export function saveFlowStore(dataDir, store) {
127 const filePath = getFlowStorePath(dataDir);
128 const dir = path.dirname(filePath);
129 if (!fs.existsSync(dir)) {
130 fs.mkdirSync(dir, { recursive: true });
131 }
132 const tmp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
133 fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf8');
134 fs.renameSync(tmp, filePath);
135 }
136
137 /**
138 * @param {string} dataDir
139 * @param {string} vaultId
140 * @returns {VaultFlowStore}
141 */
142 export function getVaultFlowStore(dataDir, vaultId) {
143 const store = loadFlowStore(dataDir);
144 if (!store.vaults[vaultId]) {
145 store.vaults[vaultId] = {
146 flows: [],
147 steps: [],
148 runs: [],
149 candidates: [],
150 projections: [],
151 tasks: [],
152 task_loops: [],
153 orchestrator_graphs: [],
154 };
155 } else if (!Array.isArray(store.vaults[vaultId].tasks)) {
156 store.vaults[vaultId].tasks = [];
157 }
158 if (!Array.isArray(store.vaults[vaultId].task_loops)) {
159 store.vaults[vaultId].task_loops = [];
160 }
161 if (!Array.isArray(store.vaults[vaultId].orchestrator_graphs)) {
162 store.vaults[vaultId].orchestrator_graphs = [];
163 }
164 return store.vaults[vaultId];
165 }
166
167 /**
168 * @param {string} flowId
169 * @param {number} ordinal
170 * @returns {string}
171 */
172 export function buildFlowStepId(flowId, ordinal) {
173 return `${flowId}#${ordinal}`;
174 }
175
176 /**
177 * @param {string} version
178 * @returns {[number, number, number]|null}
179 */
180 export function parseSemver(version) {
181 const m = SEMVER_RE.exec(version);
182 if (!m) return null;
183 return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
184 }
185
186 /**
187 * @param {[number, number, number]} a
188 * @param {[number, number, number]} b
189 * @returns {number}
190 */
191 export function compareSemver(a, b) {
192 for (let i = 0; i < 3; i += 1) {
193 if (a[i] !== b[i]) return a[i] - b[i];
194 }
195 return 0;
196 }
197
198 /**
199 * Stamp store-internal `flow_version` on validated steps before persistence.
200 *
201 * @param {StoredFlowStep[]} steps
202 * @param {string} version
203 * @returns {StoredFlowStep[]}
204 */
205 export function stampStepsForStore(steps, version) {
206 return steps.map((step) => ({ ...step, flow_version: version }));
207 }
208
209 /**
210 * Migrate legacy 7A-10b step rows (no `flow_version`) into one row per
211 * `(flow_id, version, step_id)` so prior stores remain readable.
212 *
213 * @param {VaultFlowStore} vault
214 */
215 export function normalizeVaultSteps(vault) {
216 if (!vault || !Array.isArray(vault.steps)) return;
217 const legacy = vault.steps.filter((s) => !s.flow_version);
218 if (legacy.length === 0) return;
219
220 const kept = vault.steps.filter((s) => s.flow_version);
221 /** @type {StoredFlowStep[]} */
222 const migrated = [];
223
224 for (const step of legacy) {
225 const flowRows = vault.flows.filter(
226 (f) => f.flow_id === step.flow_id && (f.steps ?? []).includes(step.step_id),
227 );
228 if (flowRows.length === 0) {
229 const sole = vault.flows.find((f) => f.flow_id === step.flow_id);
230 if (sole) migrated.push({ ...step, flow_version: sole.version });
231 continue;
232 }
233 for (const flow of flowRows) {
234 migrated.push({ ...step, flow_version: flow.version });
235 }
236 }
237
238 const seen = new Set();
239 /** @type {StoredFlowStep[]} */
240 const deduped = [];
241 for (const step of [...kept, ...migrated]) {
242 const key = `${step.flow_id}\0${step.flow_version ?? ''}\0${step.step_id}`;
243 if (seen.has(key)) continue;
244 seen.add(key);
245 deduped.push(step);
246 }
247 vault.steps = deduped;
248 }
249
250 /**
251 * Return ordered steps for one `(flow_id, version)` pair (7A-10c).
252 *
253 * @param {VaultFlowStore} vault
254 * @param {string} flowId
255 * @param {string} version
256 * @returns {StoredFlowStep[]}
257 */
258 export function stepsForFlowVersion(vault, flowId, version) {
259 normalizeVaultSteps(vault);
260 const versionsForFlow = vault.flows.filter((f) => f.flow_id === flowId).map((f) => f.version);
261 const legacySingleVersion = versionsForFlow.length === 1 && versionsForFlow[0] === version;
262 return vault.steps
263 .filter((s) => {
264 if (s.flow_id !== flowId) return false;
265 if (s.flow_version === version) return true;
266 return !s.flow_version && legacySingleVersion;
267 })
268 .sort((a, b) => a.ordinal - b.ordinal);
269 }
270
271 /**
272 * @param {unknown} scope
273 * @returns {scope is FlowScope}
274 */
275 function isFlowScope(scope) {
276 return scope === 'personal' || scope === 'project' || scope === 'org';
277 }
278
279 /**
280 * Validate a starter bundle against FLOW-V0-SPEC §1 anatomy rules.
281 *
282 * @param {{ flow?: unknown, steps?: unknown }} bundle
283 * @returns {{ ok: true, flow: StoredFlow, steps: StoredFlowStep[] } | { ok: false, reason: string }}
284 */
285 export function validateFlowBundle(bundle) {
286 if (!bundle || typeof bundle !== 'object') {
287 return { ok: false, reason: 'bundle must be an object' };
288 }
289 const flow = /** @type {Record<string, unknown>} */ (bundle.flow);
290 const stepsRaw = bundle.steps;
291 if (!flow || typeof flow !== 'object') {
292 return { ok: false, reason: 'bundle.flow is required' };
293 }
294 if (!Array.isArray(stepsRaw) || stepsRaw.length === 0) {
295 return { ok: false, reason: 'bundle.steps must be a non-empty array' };
296 }
297
298 const flowId = flow.flow_id;
299 if (typeof flowId !== 'string' || !FLOW_ID_RE.test(flowId)) {
300 return { ok: false, reason: 'invalid flow_id' };
301 }
302 if (flow.schema !== 'knowtation.flow/v0') {
303 return { ok: false, reason: 'flow.schema must be knowtation.flow/v0' };
304 }
305 if (typeof flow.title !== 'string' || !flow.title.trim()) {
306 return { ok: false, reason: 'flow.title is required' };
307 }
308 if (typeof flow.version !== 'string' || !SEMVER_RE.test(flow.version)) {
309 return { ok: false, reason: 'flow.version must be semver' };
310 }
311 if (!isFlowScope(flow.scope)) {
312 return { ok: false, reason: 'flow.scope must be personal|project|org' };
313 }
314 if (typeof flow.summary !== 'string') {
315 return { ok: false, reason: 'flow.summary is required' };
316 }
317 if (!Array.isArray(flow.steps) || flow.steps.length === 0) {
318 return { ok: false, reason: 'flow.steps must be a non-empty array' };
319 }
320 if (typeof flow.updated !== 'string' || !flow.updated.trim()) {
321 return { ok: false, reason: 'flow.updated is required' };
322 }
323 if (typeof flow.truncated !== 'boolean') {
324 return { ok: false, reason: 'flow.truncated must be boolean' };
325 }
326
327 /** @type {StoredFlowStep[]} */
328 const steps = [];
329 const stepIds = new Set();
330
331 for (const raw of stepsRaw) {
332 if (!raw || typeof raw !== 'object') {
333 return { ok: false, reason: 'each step must be an object' };
334 }
335 const step = /** @type {Record<string, unknown>} */ (raw);
336 if (step.schema !== 'knowtation.flow_step/v0') {
337 return { ok: false, reason: 'step.schema must be knowtation.flow_step/v0' };
338 }
339 if (typeof step.step_id !== 'string' || !FLOW_STEP_ID_RE.test(step.step_id)) {
340 return { ok: false, reason: 'invalid step_id' };
341 }
342 if (step.flow_id !== flowId) {
343 return { ok: false, reason: 'step.flow_id must match flow.flow_id' };
344 }
345 if (typeof step.ordinal !== 'number' || !Number.isInteger(step.ordinal) || step.ordinal < 1) {
346 return { ok: false, reason: 'step.ordinal must be a 1-based integer' };
347 }
348 if (buildFlowStepId(flowId, step.ordinal) !== step.step_id) {
349 return { ok: false, reason: 'step_id must equal flow_id#ordinal' };
350 }
351 if (typeof step.owned_job !== 'string' || !step.owned_job.trim()) {
352 return { ok: false, reason: 'step.owned_job is required' };
353 }
354 if (typeof step.instruction !== 'string' || !step.instruction.trim()) {
355 return { ok: false, reason: 'step.instruction is required' };
356 }
357 if (typeof step.trigger !== 'string' || !step.trigger.trim()) {
358 return { ok: false, reason: 'step.trigger is required (anatomy completeness)' };
359 }
360 if (typeof step.when_not_to_run !== 'string' || !step.when_not_to_run.trim()) {
361 return { ok: false, reason: 'step.when_not_to_run is required (anatomy completeness)' };
362 }
363 if (!Array.isArray(step.boundaries)) {
364 return { ok: false, reason: 'step.boundaries must be an array' };
365 }
366 if (typeof step.output_shape !== 'string' || !step.output_shape.trim()) {
367 return { ok: false, reason: 'step.output_shape is required (anatomy completeness)' };
368 }
369 const verification = step.verification;
370 if (!verification || typeof verification !== 'object') {
371 return { ok: false, reason: 'step.verification is required (anatomy completeness)' };
372 }
373 const ver = /** @type {Record<string, unknown>} */ (verification);
374 if (typeof ver.kind !== 'string' || !ver.kind.trim()) {
375 return { ok: false, reason: 'step.verification.kind is required' };
376 }
377 if (typeof ver.evidence_required !== 'boolean') {
378 return { ok: false, reason: 'step.verification.evidence_required must be boolean' };
379 }
380 if (typeof ver.description !== 'string' || !ver.description.trim()) {
381 return { ok: false, reason: 'step.verification.description is required' };
382 }
383 if (step.automatable !== 'manual' && step.automatable !== 'agent_assisted' && step.automatable !== 'automatable') {
384 return { ok: false, reason: 'step.automatable must be manual|agent_assisted|automatable' };
385 }
386 if (stepIds.has(step.step_id)) {
387 return { ok: false, reason: 'duplicate step_id' };
388 }
389 stepIds.add(step.step_id);
390 steps.push(/** @type {StoredFlowStep} */ (step));
391 }
392
393 for (const ref of flow.steps) {
394 if (typeof ref !== 'string' || !stepIds.has(ref)) {
395 return { ok: false, reason: 'flow.steps references missing step_id' };
396 }
397 }
398
399 const orderedStepIds = [...steps].sort((a, b) => a.ordinal - b.ordinal).map((s) => s.step_id);
400 if (JSON.stringify(flow.steps) !== JSON.stringify(orderedStepIds)) {
401 return { ok: false, reason: 'flow.steps must list step ids in ascending ordinal order' };
402 }
403
404 /** @type {StoredFlow} */
405 const storedFlow = {
406 schema: 'knowtation.flow/v0',
407 flow_id: flowId,
408 title: flow.title,
409 version: flow.version,
410 scope: flow.scope,
411 summary: flow.summary,
412 tags: Array.isArray(flow.tags) ? flow.tags.filter((t) => typeof t === 'string') : [],
413 steps: flow.steps,
414 inputs: Array.isArray(flow.inputs) ? flow.inputs : [],
415 vault_mirror_path: typeof flow.vault_mirror_path === 'string' ? flow.vault_mirror_path : null,
416 updated: flow.updated,
417 truncated: flow.truncated,
418 };
419
420 return { ok: true, flow: storedFlow, steps };
421 }
422
423 /**
424 * Idempotently seed canonical starter flows from flows/starter/.
425 *
426 * @param {string} dataDir
427 * @param {string} vaultId
428 * @param {{ starterDir?: string, onReject?: (name: string, reason: string) => void }} [options]
429 * @returns {{ seeded: number, skipped: number }}
430 */
431 export function seedStarterFlows(dataDir, vaultId, options = {}) {
432 const starterDir = options.starterDir ?? path.join(getRepoRoot(), STARTER_FLOWS_DIRNAME);
433 const onReject = options.onReject ?? ((name, reason) => {
434 console.warn(`[flow-store] rejected starter bundle ${name}: ${reason}`);
435 });
436
437 if (!fs.existsSync(starterDir)) {
438 return { seeded: 0, skipped: 0 };
439 }
440
441 const store = loadFlowStore(dataDir);
442 if (!store.vaults[vaultId]) {
443 store.vaults[vaultId] = {
444 flows: [],
445 steps: [],
446 runs: [],
447 candidates: [],
448 projections: [],
449 tasks: [],
450 task_loops: [],
451 orchestrator_graphs: [],
452 };
453 }
454 const vault = store.vaults[vaultId];
455
456 let seeded = 0;
457 let skipped = 0;
458
459 const files = fs.readdirSync(starterDir).filter((f) => f.startsWith('flow_') && f.endsWith('.json')).sort();
460 for (const file of files) {
461 let bundle;
462 try {
463 bundle = JSON.parse(fs.readFileSync(path.join(starterDir, file), 'utf8'));
464 } catch {
465 onReject(file, 'invalid JSON');
466 continue;
467 }
468
469 const validated = validateFlowBundle(bundle);
470 if (!validated.ok) {
471 onReject(file, validated.reason);
472 continue;
473 }
474
475 const { flow, steps } = validated;
476 const exists = vault.flows.some((f) => f.flow_id === flow.flow_id && f.version === flow.version);
477 if (exists) {
478 skipped += 1;
479 continue;
480 }
481
482 vault.flows.push(flow);
483 for (const step of stampStepsForStore(steps, flow.version)) {
484 const stepExists = vault.steps.some(
485 (s) => s.flow_id === flow.flow_id && s.flow_version === flow.version && s.step_id === step.step_id,
486 );
487 if (!stepExists) {
488 vault.steps.push(step);
489 }
490 }
491 seeded += 1;
492 }
493
494 if (seeded > 0) {
495 saveFlowStore(dataDir, store);
496 }
497
498 return { seeded, skipped };
499 }
500
501 /**
502 * Lazy seed when vault has no flows.
503 *
504 * @param {string} dataDir
505 * @param {string} vaultId
506 * @param {{ starterDir?: string }} [options]
507 */
508 function ensureStarterSeed(dataDir, vaultId, options = {}) {
509 const store = loadFlowStore(dataDir);
510 const vault = store.vaults[vaultId];
511 if (vault && vault.flows.length > 0) return;
512 seedStarterFlows(dataDir, vaultId, options);
513 }
514
515 /**
516 * @param {StoredFlow} flow
517 * @param {number} stepCount
518 * @returns {object}
519 */
520 export function flowSummaryForClient(flow, stepCount) {
521 return {
522 schema: 'knowtation.flow/v0',
523 flow_id: flow.flow_id,
524 title: flow.title,
525 version: flow.version,
526 scope: flow.scope,
527 summary: flow.summary,
528 tags: flow.tags ?? [],
529 step_count: stepCount,
530 updated: flow.updated,
531 truncated: flow.truncated,
532 };
533 }
534
535 /**
536 * @param {StoredFlow} flow
537 * @param {StoredFlowStep[]} steps
538 * @returns {{ flow: object, steps: object[] }}
539 */
540 export function flowDefinitionForClient(flow, steps) {
541 return {
542 flow: {
543 schema: flow.schema,
544 flow_id: flow.flow_id,
545 title: flow.title,
546 version: flow.version,
547 scope: flow.scope,
548 summary: flow.summary,
549 tags: flow.tags ?? [],
550 steps: flow.steps,
551 inputs: flow.inputs ?? [],
552 vault_mirror_path: flow.vault_mirror_path ?? null,
553 updated: flow.updated,
554 truncated: flow.truncated,
555 },
556 steps: steps.map((step) => ({
557 schema: step.schema,
558 step_id: step.step_id,
559 flow_id: step.flow_id,
560 ordinal: step.ordinal,
561 owned_job: step.owned_job,
562 instruction: step.instruction,
563 trigger: step.trigger,
564 when_not_to_run: step.when_not_to_run,
565 requires: step.requires ?? [],
566 boundaries: step.boundaries,
567 skill_refs: step.skill_refs ?? [],
568 inputs: step.inputs ?? [],
569 outputs: step.outputs ?? [],
570 output_shape: step.output_shape,
571 verification: step.verification,
572 automatable: step.automatable,
573 })),
574 };
575 }
576
577 /**
578 * @param {VaultFlowStore} vault
579 * @param {string} flowId
580 * @param {string} version
581 * @returns {number}
582 */
583 function countStepsForFlow(vault, flowId, version) {
584 return stepsForFlowVersion(vault, flowId, version).length;
585 }
586
587 /**
588 * List scope-visible flows (content-minimized).
589 *
590 * @param {string} dataDir
591 * @param {string} vaultId
592 * @param {{
593 * visibleScopes?: Set<FlowScope>,
594 * filterScopes?: Set<FlowScope>,
595 * effectiveScope: FlowScope,
596 * tag?: string,
597 * limit?: number,
598 * starterDir?: string,
599 * }} query
600 * @returns {{ schema: 'knowtation.flow_list/v0', vault_id: string, effective_scope: FlowScope, flows: object[], truncated: boolean }}
601 */
602 export function listFlows(dataDir, vaultId, query) {
603 ensureStarterSeed(dataDir, vaultId, { starterDir: query.starterDir });
604
605 const visibleScopes = query.visibleScopes ?? query.filterScopes ?? new Set(['personal']);
606 const filterScopes = query.filterScopes ?? visibleScopes;
607 const tag = typeof query.tag === 'string' && query.tag.trim() ? query.tag.trim() : '';
608 let limit = typeof query.limit === 'number' ? query.limit : MAX_FLOW_SUMMARIES;
609 if (!Number.isInteger(limit) || limit < 1) limit = MAX_FLOW_SUMMARIES;
610 if (limit > MAX_FLOW_SUMMARIES) limit = MAX_FLOW_SUMMARIES;
611
612 const store = loadFlowStore(dataDir);
613 const vault = store.vaults[vaultId] ?? {
614 flows: [],
615 steps: [],
616 runs: [],
617 candidates: [],
618 projections: [],
619 tasks: [],
620 task_loops: [],
621 orchestrator_graphs: [],
622 };
623
624 /** @type {Map<string, StoredFlow>} */
625 const latestById = new Map();
626 for (const flow of vault.flows) {
627 if (!filterScopes.has(flow.scope)) continue;
628 if (tag && !(flow.tags ?? []).includes(tag)) continue;
629 const parsed = parseSemver(flow.version);
630 if (!parsed) continue;
631 const existing = latestById.get(flow.flow_id);
632 if (!existing) {
633 latestById.set(flow.flow_id, flow);
634 continue;
635 }
636 const existingParsed = parseSemver(existing.version);
637 if (existingParsed && compareSemver(parsed, existingParsed) > 0) {
638 latestById.set(flow.flow_id, flow);
639 }
640 }
641
642 let candidates = [...latestById.values()].sort((a, b) => {
643 const t = Date.parse(b.updated) - Date.parse(a.updated);
644 if (t !== 0) return t;
645 return a.flow_id.localeCompare(b.flow_id);
646 });
647
648 const totalMatching = candidates.length;
649 let truncated = totalMatching > limit;
650 if (candidates.length > limit) {
651 candidates = candidates.slice(0, limit);
652 }
653
654 const flows = candidates.map((flow) => flowSummaryForClient(flow, countStepsForFlow(vault, flow.flow_id, flow.version)));
655
656 return {
657 schema: 'knowtation.flow_list/v0',
658 vault_id: vaultId,
659 effective_scope: query.effectiveScope,
660 flows,
661 truncated,
662 };
663 }
664
665 /**
666 * Resolve the latest stored version of a flow **regardless of reader scope**.
667 *
668 * Used by the authoring write-back path (approve→apply reconcile and the
669 * propose-time concurrency precheck), where the server compares against the
670 * actual canonical state, not a reader-filtered projection.
671 *
672 * @param {VaultFlowStore} vault
673 * @param {string} flowId
674 * @returns {{ flow: StoredFlow, steps: StoredFlowStep[] } | null}
675 */
676 export function latestStoredFlow(vault, flowId) {
677 if (!vault) return null;
678 const matching = vault.flows.filter((f) => f.flow_id === flowId);
679 if (matching.length === 0) return null;
680 let flow = matching[0];
681 for (const candidate of matching) {
682 const a = parseSemver(candidate.version);
683 const b = parseSemver(flow.version);
684 if (a && b && compareSemver(a, b) > 0) flow = candidate;
685 }
686 const steps = stepsForFlowVersion(vault, flowId, flow.version);
687 return { flow, steps };
688 }
689
690 /**
691 * Reconcile a validated bundle into the Flow index as a **new (flow_id, version)
692 * row** (Phase 7A-L1b; the only index write besides seed).
693 *
694 * Carry-forward constraint (FLOW-AUTHORING-WRITEBACK-CONTRACT-7A-L1 §4): an edit
695 * is reconciled as a new version record — an existing version row is never
696 * mutated in place. The flow row is upserted by `(flow_id, version)` so prior
697 * versions stay pinnable. Step bodies are keyed by `(flow_id, flow_version,
698 * step_id)` (7A-10c) so divergent step text across versions is preserved.
699 * Writes atomically (tmp + rename) so a failed reconcile leaves zero partial state.
700 *
701 * @param {string} dataDir
702 * @param {string} vaultId
703 * @param {StoredFlow} flow - validated flow record (from `validateFlowBundle`).
704 * @param {StoredFlowStep[]} steps - validated ordered steps.
705 * @returns {{ created: boolean, version: string }}
706 */
707 export function upsertFlowVersion(dataDir, vaultId, flow, steps) {
708 const store = loadFlowStore(dataDir);
709 if (!store.vaults[vaultId]) {
710 store.vaults[vaultId] = {
711 flows: [],
712 steps: [],
713 runs: [],
714 candidates: [],
715 projections: [],
716 tasks: [],
717 task_loops: [],
718 orchestrator_graphs: [],
719 };
720 }
721 const vault = store.vaults[vaultId];
722
723 const idx = vault.flows.findIndex((f) => f.flow_id === flow.flow_id && f.version === flow.version);
724 const created = idx === -1;
725 if (created) {
726 vault.flows.push(flow);
727 } else {
728 vault.flows[idx] = flow;
729 }
730
731 vault.steps = vault.steps.filter(
732 (s) => !(s.flow_id === flow.flow_id && s.flow_version === flow.version),
733 );
734 for (const step of stampStepsForStore(steps, flow.version)) {
735 vault.steps.push(step);
736 }
737
738 saveFlowStore(dataDir, store);
739 return { created, version: flow.version };
740 }
741
742 /**
743 * Get one flow definition + ordered steps, or null when missing/invisible.
744 *
745 * @param {string} dataDir
746 * @param {string} vaultId
747 * @param {string} flowId
748 * @param {{
749 * visibleScopes?: Set<FlowScope>,
750 * filterScopes?: Set<FlowScope>,
751 * version?: string,
752 * starterDir?: string,
753 * }} query
754 * @returns {{ schema: 'knowtation.flow_get/v0', vault_id: string, flow: object, steps: object[] } | null}
755 */
756 export function getFlow(dataDir, vaultId, flowId, query) {
757 if (!FLOW_ID_RE.test(flowId)) {
758 return null;
759 }
760
761 ensureStarterSeed(dataDir, vaultId, { starterDir: query.starterDir });
762
763 const filterScopes = query.filterScopes ?? query.visibleScopes ?? new Set(['personal']);
764 const pinnedVersion = typeof query.version === 'string' && query.version.trim() ? query.version.trim() : '';
765
766 if (pinnedVersion && !SEMVER_RE.test(pinnedVersion)) {
767 return null;
768 }
769
770 const store = loadFlowStore(dataDir);
771 const vault = store.vaults[vaultId];
772 if (!vault) return null;
773
774 const matching = vault.flows.filter((f) => {
775 if (f.flow_id !== flowId) return false;
776 if (!filterScopes.has(f.scope)) return false;
777 if (pinnedVersion) return f.version === pinnedVersion;
778 return true;
779 });
780
781 if (matching.length === 0) return null;
782
783 let flow = matching[0];
784 if (!pinnedVersion) {
785 for (const candidate of matching) {
786 const a = parseSemver(candidate.version);
787 const b = parseSemver(flow.version);
788 if (a && b && compareSemver(a, b) > 0) {
789 flow = candidate;
790 }
791 }
792 }
793
794 let steps = stepsForFlowVersion(vault, flowId, flow.version);
795
796 let truncated = false;
797 if (steps.length > MAX_STEPS_PER_FLOW) {
798 steps = steps.slice(0, MAX_STEPS_PER_FLOW);
799 truncated = true;
800 }
801
802 const client = flowDefinitionForClient(
803 truncated ? { ...flow, truncated: true } : flow,
804 steps,
805 );
806
807 return {
808 schema: 'knowtation.flow_get/v0',
809 vault_id: vaultId,
810 flow: client.flow,
811 steps: client.steps,
812 };
813 }
814
815 /**
816 * Build the default portable run pointer for a canonical run_id.
817 *
818 * @param {string} runId
819 * @returns {string}
820 */
821 export function buildDefaultRunRef(runId) {
822 return `flow_run:${runId}`;
823 }
824
825 /**
826 * @param {string} input
827 * @returns {boolean}
828 */
829 export function isValidRunLookupKey(input) {
830 if (typeof input !== 'string' || !input.trim()) return false;
831 const key = input.trim();
832 return FLOW_RUN_ID_RE.test(key) || FLOW_RUN_REF_RE.test(key);
833 }
834
835 /**
836 * Locate a run in a vault by canonical run_id or portable run_ref.
837 *
838 * @param {VaultFlowStore|null|undefined} vault
839 * @param {string} lookupKey
840 * @returns {object|null}
841 */
842 export function findRunInVault(vault, lookupKey) {
843 if (!vault || !Array.isArray(vault.runs)) return null;
844 const key = lookupKey.trim();
845 if (FLOW_RUN_ID_RE.test(key)) {
846 return vault.runs.find((r) => r.run_id === key) ?? null;
847 }
848 if (FLOW_RUN_REF_RE.test(key)) {
849 return vault.runs.find((r) => r.run_ref === key) ?? null;
850 }
851 return null;
852 }
853
854 /**
855 * @param {VaultFlowStore|null|undefined} vault
856 * @param {string} lookupKey
857 * @param {Set<FlowScope>} visibleScopes
858 * @returns {object|null}
859 */
860 export function findVisibleRun(vault, lookupKey, visibleScopes) {
861 const run = findRunInVault(vault, lookupKey);
862 if (!run) return null;
863 if (!visibleScopes.has(run.scope)) return null;
864 return run;
865 }
866
867 /**
868 * Project a stored run for wire clients (content-minimized, pointer-only).
869 *
870 * @param {object} run
871 * @returns {object}
872 */
873 export function runForClient(run) {
874 return {
875 schema: FLOW_RUN_SCHEMA,
876 run_id: run.run_id,
877 run_ref: typeof run.run_ref === 'string' ? run.run_ref : buildDefaultRunRef(run.run_id),
878 flow_id: run.flow_id,
879 flow_version: run.flow_version,
880 scope: run.scope,
881 status: run.status,
882 step_states: Array.isArray(run.step_states)
883 ? run.step_states.map((s) => ({
884 step_id: s.step_id,
885 status: s.status,
886 evidence_ref: s.evidence_ref ?? null,
887 verified: s.verified === true,
888 }))
889 : [],
890 started: run.started,
891 provenance: {
892 actor: run.provenance?.actor ?? '',
893 harness: run.provenance?.harness ?? 'unknown',
894 },
895 task_ref: typeof run.task_ref === 'string' ? run.task_ref : null,
896 external_ref: typeof run.external_ref === 'string' ? run.external_ref : null,
897 };
898 }
899
900 /**
901 * Seed the SD-2 / overseer anchor run when absent (read-only; no run-write gate).
902 *
903 * @param {string} dataDir
904 * @param {string} vaultId
905 * @returns {{ seeded: boolean }}
906 */
907 export function seedOverseerAnchorRun(dataDir, vaultId) {
908 const store = loadFlowStore(dataDir);
909 if (!store.vaults[vaultId]) {
910 store.vaults[vaultId] = {
911 flows: [],
912 steps: [],
913 runs: [],
914 candidates: [],
915 projections: [],
916 tasks: [],
917 task_loops: [],
918 orchestrator_graphs: [],
919 };
920 }
921 const vault = store.vaults[vaultId];
922 const runId = 'run_overseer_in_progress';
923 const runRef = OVERSEER_FIXTURE_RUN_REF;
924 const exists = vault.runs.some((r) => r.run_id === runId || r.run_ref === runRef);
925 if (exists) {
926 return { seeded: false };
927 }
928
929 vault.runs.push({
930 schema: FLOW_RUN_SCHEMA,
931 run_id: runId,
932 run_ref: runRef,
933 flow_id: 'flow_overseer_handover',
934 flow_version: '0.1.0',
935 scope: 'project',
936 status: 'in_progress',
937 task_ref: 'task_2g_handover_001',
938 external_ref: 'musehub:commit:abc123def456',
939 step_states: [
940 {
941 step_id: 'flow_overseer_handover#1',
942 status: 'done',
943 evidence_ref: 'artifact:snapshot-001',
944 verified: true,
945 },
946 {
947 step_id: 'flow_overseer_handover#2',
948 status: 'blocked',
949 evidence_ref: null,
950 verified: false,
951 },
952 ],
953 started: '2026-06-19T10:00:00Z',
954 provenance: {
955 actor: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
956 harness: 'seed',
957 },
958 });
959 saveFlowStore(dataDir, store);
960 return { seeded: true };
961 }
962
963 /**
964 * Lazy seed anchor run on first flow_run read.
965 *
966 * @param {string} dataDir
967 * @param {string} vaultId
968 */
969 function ensureRunSeed(dataDir, vaultId) {
970 seedOverseerAnchorRun(dataDir, vaultId);
971 }
972
973 /**
974 * List scope-visible flow runs (content-minimized).
975 *
976 * @param {string} dataDir
977 * @param {string} vaultId
978 * @param {{
979 * visibleScopes?: Set<FlowScope>,
980 * filterScopes?: Set<FlowScope>,
981 * effectiveScope: FlowScope,
982 * flowId?: string,
983 * limit?: number,
984 * }} query
985 * @returns {{ schema: typeof FLOW_RUN_LIST_SCHEMA, vault_id: string, effective_scope: FlowScope, runs: object[], truncated: boolean }}
986 */
987 export function listFlowRuns(dataDir, vaultId, query) {
988 ensureRunSeed(dataDir, vaultId);
989
990 const filterScopes = query.filterScopes ?? query.visibleScopes ?? new Set(['personal']);
991 const flowId = typeof query.flowId === 'string' ? query.flowId.trim() : '';
992 let limit = typeof query.limit === 'number' ? query.limit : MAX_FLOW_RUNS_LIST;
993 if (!Number.isInteger(limit) || limit < 1) limit = MAX_FLOW_RUNS_LIST;
994 if (limit > MAX_FLOW_RUNS_LIST) limit = MAX_FLOW_RUNS_LIST;
995
996 const store = loadFlowStore(dataDir);
997 const vault = store.vaults[vaultId];
998 const runs = vault && Array.isArray(vault.runs) ? vault.runs : [];
999
1000 let filtered = runs.filter((r) => {
1001 if (!filterScopes.has(r.scope)) return false;
1002 if (flowId && r.flow_id !== flowId) return false;
1003 return true;
1004 });
1005
1006 filtered.sort((a, b) => {
1007 const t = Date.parse(b.started ?? 0) - Date.parse(a.started ?? 0);
1008 if (t !== 0) return t;
1009 return (a.run_id ?? '').localeCompare(b.run_id ?? '');
1010 });
1011
1012 const totalMatching = filtered.length;
1013 let truncated = totalMatching > limit;
1014 if (filtered.length > limit) {
1015 filtered = filtered.slice(0, limit);
1016 }
1017
1018 return {
1019 schema: FLOW_RUN_LIST_SCHEMA,
1020 vault_id: vaultId,
1021 effective_scope: query.effectiveScope,
1022 runs: filtered.map(runForClient),
1023 truncated,
1024 };
1025 }
1026
1027 /**
1028 * Get one flow run by run_id or portable run_ref, or null when missing/invisible.
1029 *
1030 * @param {string} dataDir
1031 * @param {string} vaultId
1032 * @param {string} lookupKey
1033 * @param {{ visibleScopes?: Set<FlowScope>, filterScopes?: Set<FlowScope> }} query
1034 * @returns {{ schema: typeof FLOW_RUN_GET_SCHEMA, vault_id: string, run: object } | null}
1035 */
1036 export function getFlowRun(dataDir, vaultId, lookupKey, query) {
1037 if (!isValidRunLookupKey(lookupKey)) {
1038 return null;
1039 }
1040
1041 ensureRunSeed(dataDir, vaultId);
1042
1043 const filterScopes = query.filterScopes ?? query.visibleScopes ?? new Set(['personal']);
1044 const store = loadFlowStore(dataDir);
1045 const vault = store.vaults[vaultId];
1046 const run = findVisibleRun(vault, lookupKey, filterScopes);
1047 if (!run) return null;
1048
1049 return {
1050 schema: FLOW_RUN_GET_SCHEMA,
1051 vault_id: vaultId,
1052 run: runForClient(run),
1053 };
1054 }
1055
1056 /**
1057 * Persist a new or updated run row atomically.
1058 *
1059 * @param {string} dataDir
1060 * @param {string} vaultId
1061 * @param {object} run
1062 * @param {{ create?: boolean }} [options]
1063 * @returns {{ ok: true, run: object } | { ok: false, reason: string }}
1064 */
1065 export function persistFlowRun(dataDir, vaultId, run, options = {}) {
1066 const store = loadFlowStore(dataDir);
1067 if (!store.vaults[vaultId]) {
1068 store.vaults[vaultId] = {
1069 flows: [],
1070 steps: [],
1071 runs: [],
1072 candidates: [],
1073 projections: [],
1074 tasks: [],
1075 task_loops: [],
1076 orchestrator_graphs: [],
1077 };
1078 }
1079 const vault = store.vaults[vaultId];
1080 const idx = vault.runs.findIndex((r) => r.run_id === run.run_id);
1081 if (options.create === true && idx >= 0) {
1082 return { ok: false, reason: 'run_exists' };
1083 }
1084 if (options.create !== true && idx < 0) {
1085 return { ok: false, reason: 'unknown_run' };
1086 }
1087 const row = {
1088 ...run,
1089 run_ref: typeof run.run_ref === 'string' ? run.run_ref : buildDefaultRunRef(run.run_id),
1090 };
1091 if (idx >= 0) {
1092 vault.runs[idx] = row;
1093 } else {
1094 vault.runs.push(row);
1095 }
1096 saveFlowStore(dataDir, store);
1097 return { ok: true, run: row };
1098 }
1099
1100 /**
1101 * Upsert a `knowtation.flow_candidate/v0` record (latest row wins by candidate_id).
1102 *
1103 * @param {string} dataDir
1104 * @param {string} vaultId
1105 * @param {object} candidate
1106 * @returns {object}
1107 */
1108 export function upsertCandidate(dataDir, vaultId, candidate) {
1109 const store = loadFlowStore(dataDir);
1110 if (!store.vaults[vaultId]) {
1111 store.vaults[vaultId] = {
1112 flows: [],
1113 steps: [],
1114 runs: [],
1115 candidates: [],
1116 projections: [],
1117 tasks: [],
1118 task_loops: [],
1119 orchestrator_graphs: [],
1120 };
1121 }
1122 const vault = store.vaults[vaultId];
1123 const idx = vault.candidates.findIndex((c) => c.candidate_id === candidate.candidate_id);
1124 const row = { ...candidate, updated: candidate.updated ?? new Date().toISOString() };
1125 if (idx === -1) {
1126 vault.candidates.push(row);
1127 } else {
1128 vault.candidates[idx] = row;
1129 }
1130 saveFlowStore(dataDir, store);
1131 return row;
1132 }
1133
1134 /**
1135 * Get one candidate when readable in caller scope, or null (no existence leak).
1136 *
1137 * @param {string} dataDir
1138 * @param {string} vaultId
1139 * @param {string} candidateId
1140 * @param {Set<import('./flow-scope.mjs').FlowScope>} visibleScopes
1141 * @returns {object|null}
1142 */
1143 export function getCandidate(dataDir, vaultId, candidateId, visibleScopes) {
1144 if (!FLOW_CANDIDATE_ID_RE.test(candidateId)) return null;
1145 const store = loadFlowStore(dataDir);
1146 const vault = store.vaults[vaultId];
1147 if (!vault) return null;
1148 const row = vault.candidates.find((c) => c.candidate_id === candidateId);
1149 if (!row) return null;
1150 if (!visibleScopes.has(row.scope_hint)) return null;
1151 return row;
1152 }
1153
1154 /**
1155 * List candidates in a vault (content-minimized rows).
1156 *
1157 * @param {string} dataDir
1158 * @param {string} vaultId
1159 * @param {{ limit?: number, statusFilter?: string }} [query]
1160 * @returns {{ candidates: object[], truncated: boolean }}
1161 */
1162 export function listCandidatesInVault(dataDir, vaultId, query = {}) {
1163 let limit = typeof query.limit === 'number' ? query.limit : 50;
1164 if (!Number.isInteger(limit) || limit < 1) limit = 50;
1165 if (limit > 50) limit = 50;
1166
1167 const store = loadFlowStore(dataDir);
1168 const vault = store.vaults[vaultId] ?? { candidates: [] };
1169 let rows = [...(vault.candidates ?? [])];
1170 if (query.statusFilter) {
1171 rows = rows.filter((c) => c.status === query.statusFilter);
1172 }
1173 rows.sort((a, b) => Date.parse(b.updated ?? 0) - Date.parse(a.updated ?? 0));
1174 const truncated = rows.length > limit;
1175 if (rows.length > limit) rows = rows.slice(0, limit);
1176 return { candidates: rows, truncated };
1177 }
1178
1179 /**
1180 * Update candidate terminal/non-terminal status.
1181 *
1182 * @param {string} dataDir
1183 * @param {string} vaultId
1184 * @param {string} candidateId
1185 * @param {string} status
1186 * @returns {object|null}
1187 */
1188 export function updateCandidateStatus(dataDir, vaultId, candidateId, status) {
1189 const store = loadFlowStore(dataDir);
1190 const vault = store.vaults[vaultId];
1191 if (!vault) return null;
1192 const idx = vault.candidates.findIndex((c) => c.candidate_id === candidateId);
1193 if (idx === -1) return null;
1194 const prev = vault.candidates[idx].status;
1195 if (prev !== 'pending_review') return null;
1196 vault.candidates[idx] = {
1197 ...vault.candidates[idx],
1198 status,
1199 updated: new Date().toISOString(),
1200 };
1201 saveFlowStore(dataDir, store);
1202 return vault.candidates[idx];
1203 }
File History 2 commits
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 11 days ago
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor 31 days ago