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