flow-execution.mjs
1,241 lines 39.6 KB
Raw
sha256:cfe8c8cf68336f6d46318bd40610c18d9ff7df231df2fb190af1f5a9c4f4f93b fix(flow-store): versioned step keying for multi-version fl… Human minor ⚠ breaking 4 hours ago
1 /**
2 * Flow execution gate — run advancement, consent ledger, automatable orchestration stubs
3 * (Phase 7A-L3b).
4 *
5 * Run operational state mutates in the flow store `runs[]`. Durable knowledge outcomes route
6 * through proposals (review-before-write). External-agent grants (SD-5) never substitute for
7 * execution consent (SD-6).
8 *
9 * `FLOW_RUN_WRITES_ENABLED` and `FLOW_AUTOMATABLE_EXECUTION_ENABLED` default **off**.
10 *
11 * @see docs/FLOW-EXECUTION-GATE-CONTRACT-7A-L3.md
12 */
13
14 import fs from 'fs';
15 import path from 'path';
16 import { createHash, randomBytes } from 'crypto';
17
18 import {
19 loadFlowStore,
20 saveFlowStore,
21 getFlow,
22 stepsForFlowVersion,
23 FLOW_ID_RE,
24 FLOW_RUN_ID_RE,
25 SEMVER_RE,
26 buildFlowStepId,
27 } from './flow-store.mjs';
28 import { resolveFlowVisibleScopes } from './flow-scope.mjs';
29 import { hashActorLabel } from './external-agent.mjs';
30 import { FLOW_PROPOSAL_SOURCE, FLOW_REVIEW_QUEUE } from './flow-authoring.mjs';
31
32 export const FLOW_EXECUTION_POLICY_FILE = 'hub_flow_execution_policy.json';
33 export const FLOW_EXECUTION_CONSENTS_FILE = 'hub_flow_execution_consents.json';
34 export const FLOW_IN_FLIGHT_EXECUTIONS_FILE = 'hub_flow_in_flight_executions.json';
35
36 export const FLOW_RUN_SCHEMA = 'knowtation.flow_run/v0';
37 export const FLOW_RUN_START_SCHEMA = 'knowtation.flow_run_start/v0';
38 export const FLOW_RUN_LIST_SCHEMA = 'knowtation.flow_run_list/v0';
39 export const FLOW_EXECUTION_CONSENT_SCHEMA = 'knowtation.flow_execution_consent/v0';
40 export const FLOW_EXECUTION_CONSENT_MINT_SCHEMA = 'knowtation.flow_execution_consent_mint/v0';
41 export const FLOW_EXECUTE_AUTOMATABLE_SCHEMA = 'knowtation.flow_execute_automatable/v0';
42
43 export const CONSENT_ID_PREFIX = 'fcons_';
44 export const EXECUTION_ID_PREFIX = 'fexec_';
45 export const DEFAULT_CONSENT_TTL_SECONDS = 3600;
46 export const MAX_CONSENT_TTL_SECONDS = 86400;
47 export const DEFAULT_COST_CAP_UNITS = 100;
48
49 /** Bounded skip reasons for manual advance (never free-text alone). */
50 export const FLOW_SKIP_REASONS = ['policy', 'not_applicable', 'blocked_dependency'];
51
52 /** Internal skill-ref kinds allowed for automatable execution (never external_tool). */
53 export const AUTOMATABLE_SKILL_KINDS = new Set(['mcp_prompt', 'skill_pack', 'cli']);
54
55 /** @typedef {import('./flow-scope.mjs').FlowScope} FlowScope */
56
57 /** @param {unknown} v */
58 function envTriState(v) {
59 if (v === '1' || v === 'true') return true;
60 if (v === '0' || v === 'false') return false;
61 return null;
62 }
63
64 /**
65 * @param {string} dataDir
66 * @returns {object}
67 */
68 export function readFlowExecutionPolicyFile(dataDir) {
69 if (!dataDir) return {};
70 const fp = path.join(dataDir, FLOW_EXECUTION_POLICY_FILE);
71 try {
72 if (!fs.existsSync(fp)) return {};
73 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
74 return j && typeof j === 'object' ? j : {};
75 } catch {
76 return {};
77 }
78 }
79
80 /**
81 * @param {string} dataDir
82 * @returns {boolean}
83 */
84 export function getFlowRunWritesEnabled(dataDir) {
85 const fromEnv = envTriState(process.env.FLOW_RUN_WRITES_ENABLED);
86 if (fromEnv !== null) return fromEnv;
87 const policy = readFlowExecutionPolicyFile(dataDir);
88 if (typeof policy.flow_run_writes_enabled === 'boolean') {
89 return policy.flow_run_writes_enabled;
90 }
91 return false;
92 }
93
94 /**
95 * @param {string} dataDir
96 * @returns {boolean}
97 */
98 export function getFlowAutomatableExecutionEnabled(dataDir) {
99 const fromEnv = envTriState(process.env.FLOW_AUTOMATABLE_EXECUTION_ENABLED);
100 if (fromEnv !== null) return fromEnv;
101 const policy = readFlowExecutionPolicyFile(dataDir);
102 const exec = policy.execution;
103 if (exec && typeof exec === 'object' && typeof exec.automatable_enabled === 'boolean') {
104 return exec.automatable_enabled;
105 }
106 return false;
107 }
108
109 /**
110 * @param {string} dataDir
111 * @returns {boolean}
112 */
113 export function getFlowExecutionPolicyForbidden(dataDir) {
114 const fromEnv = envTriState(process.env.FLOW_EXECUTION_POLICY_FORBIDDEN);
115 if (fromEnv !== null) return fromEnv;
116 const policy = readFlowExecutionPolicyFile(dataDir);
117 const exec = policy.execution;
118 if (exec && typeof exec === 'object' && typeof exec.forbidden === 'boolean') {
119 return exec.forbidden;
120 }
121 return false;
122 }
123
124 /**
125 * @param {string} dataDir
126 * @returns {{
127 * allowedLanes: Set<string>,
128 * defaultCostCapUnits: number,
129 * defaultTtlSeconds: number,
130 * maxTtlSeconds: number,
131 * automatableForbidden: boolean,
132 * }}
133 */
134 export function readVaultExecutionPolicy(dataDir) {
135 const policy = readFlowExecutionPolicyFile(dataDir);
136 const exec = policy.execution && typeof policy.execution === 'object' ? policy.execution : {};
137 const lanes = new Set();
138 if (Array.isArray(exec.allowed_lanes)) {
139 for (const lane of exec.allowed_lanes) {
140 if (typeof lane === 'string' && lane.trim()) lanes.add(lane.trim());
141 }
142 }
143 if (lanes.size === 0) lanes.add('local_default');
144 const defaultCostCapUnits =
145 typeof exec.default_cost_cap_units === 'number' && exec.default_cost_cap_units > 0
146 ? exec.default_cost_cap_units
147 : DEFAULT_COST_CAP_UNITS;
148 const defaultTtlSeconds =
149 typeof exec.default_ttl_seconds === 'number' && exec.default_ttl_seconds > 0
150 ? exec.default_ttl_seconds
151 : DEFAULT_CONSENT_TTL_SECONDS;
152 const maxTtlSeconds =
153 typeof exec.max_ttl_seconds === 'number' && exec.max_ttl_seconds > 0
154 ? exec.max_ttl_seconds
155 : MAX_CONSENT_TTL_SECONDS;
156 const automatableForbidden =
157 typeof exec.automatable_forbidden === 'boolean' ? exec.automatable_forbidden : false;
158 return {
159 allowedLanes: lanes,
160 defaultCostCapUnits,
161 defaultTtlSeconds,
162 maxTtlSeconds,
163 automatableForbidden,
164 };
165 }
166
167 /**
168 * Import sandbox: reject bundles declaring non-manual automatable when policy forbids.
169 *
170 * @param {object[]} steps
171 * @param {string} dataDir
172 * @returns {{ ok: true } | { ok: false, denied: string[] }}
173 */
174 export function validateImportAutomatableSteps(steps, dataDir) {
175 const vaultPolicy = readVaultExecutionPolicy(dataDir);
176 if (!vaultPolicy.automatableForbidden) return { ok: true };
177 const denied = [];
178 for (const step of steps) {
179 if (step && step.automatable && step.automatable !== 'manual') {
180 denied.push(step.step_id ?? 'unknown');
181 }
182 }
183 if (denied.length > 0) return { ok: false, denied };
184 return { ok: true };
185 }
186
187 /**
188 * @param {object} run
189 * @returns {object}
190 */
191 export function runForClient(run) {
192 return {
193 schema: FLOW_RUN_SCHEMA,
194 run_id: run.run_id,
195 flow_id: run.flow_id,
196 flow_version: run.flow_version,
197 scope: run.scope,
198 status: run.status,
199 step_states: Array.isArray(run.step_states)
200 ? run.step_states.map((s) => ({
201 step_id: s.step_id,
202 status: s.status,
203 evidence_ref: s.evidence_ref ?? null,
204 verified: s.verified === true,
205 }))
206 : [],
207 started: run.started,
208 provenance: {
209 actor: run.provenance?.actor ?? '',
210 harness: run.provenance?.harness ?? 'unknown',
211 },
212 task_ref: typeof run.task_ref === 'string' ? run.task_ref : null,
213 external_ref: typeof run.external_ref === 'string' ? run.external_ref : null,
214 };
215 }
216
217 /**
218 * @param {object} consent
219 * @returns {object}
220 */
221 export function consentForClient(consent) {
222 return {
223 schema: FLOW_EXECUTION_CONSENT_SCHEMA,
224 consent_id: consent.consent_id,
225 vault_id: consent.vault_id,
226 scope: consent.scope,
227 run_id: consent.run_id,
228 flow_id: consent.flow_id,
229 flow_version: consent.flow_version,
230 allowed_lanes: consent.allowed_lanes,
231 cost_cap_units: consent.cost_cap_units,
232 cost_consumed_units: consent.cost_consumed_units,
233 actor_hash: consent.actor_hash,
234 expires_at: consent.expires_at,
235 revoked_at: consent.revoked_at ?? null,
236 };
237 }
238
239 /**
240 * @param {string} dataDir
241 * @returns {string}
242 */
243 function consentsFilePath(dataDir) {
244 return path.join(dataDir, FLOW_EXECUTION_CONSENTS_FILE);
245 }
246
247 /**
248 * @param {string} dataDir
249 * @returns {{ vaults: Record<string, { consents: object[] }> }}
250 */
251 export function loadExecutionConsentsStore(dataDir) {
252 const fp = consentsFilePath(dataDir);
253 if (!fs.existsSync(fp)) return { vaults: {} };
254 try {
255 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
256 if (!j || typeof j !== 'object') return { vaults: {} };
257 return { vaults: j.vaults && typeof j.vaults === 'object' ? j.vaults : {} };
258 } catch {
259 return { vaults: {} };
260 }
261 }
262
263 /**
264 * @param {string} dataDir
265 * @param {{ vaults: Record<string, { consents: object[] }> }} store
266 */
267 export function saveExecutionConsentsStore(dataDir, store) {
268 const fp = consentsFilePath(dataDir);
269 fs.mkdirSync(path.dirname(fp), { recursive: true });
270 fs.writeFileSync(fp, JSON.stringify(store, null, 2), 'utf8');
271 }
272
273 /**
274 * @param {string} dataDir
275 * @returns {string}
276 */
277 function inFlightFilePath(dataDir) {
278 return path.join(dataDir, FLOW_IN_FLIGHT_EXECUTIONS_FILE);
279 }
280
281 /**
282 * @param {string} dataDir
283 * @returns {{ entries: Record<string, object> }}
284 */
285 export function loadInFlightExecutionsStore(dataDir) {
286 const fp = inFlightFilePath(dataDir);
287 if (!fs.existsSync(fp)) return { entries: {} };
288 try {
289 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
290 if (!j || typeof j !== 'object') return { entries: {} };
291 return { entries: j.entries && typeof j.entries === 'object' ? j.entries : {} };
292 } catch {
293 return { entries: {} };
294 }
295 }
296
297 /**
298 * @param {string} dataDir
299 * @param {{ entries: Record<string, object> }} store
300 */
301 export function saveInFlightExecutionsStore(dataDir, store) {
302 const fp = inFlightFilePath(dataDir);
303 fs.mkdirSync(path.dirname(fp), { recursive: true });
304 fs.writeFileSync(fp, JSON.stringify(store, null, 2), 'utf8');
305 }
306
307 /**
308 * @param {string} runId
309 * @param {string} stepId
310 * @param {string} consentId
311 * @returns {string}
312 */
313 export function inFlightExecutionKey(runId, stepId, consentId) {
314 return `${runId}|${stepId}|${consentId}`;
315 }
316
317 /**
318 * ModelRuntimeAdapter orchestration stub — bounded evidence pointer, no prompts/completions.
319 *
320 * @param {{ lane: string, dryRun?: boolean }} input
321 * @returns {{ status: string, evidence_ref: string|null, cost_units: number }}
322 */
323 export function runModelOrchestrationStub(input) {
324 if (input.dryRun === true) {
325 return { status: 'completed', evidence_ref: null, cost_units: 0 };
326 }
327 const hash = createHash('sha256')
328 .update(`stub|${input.lane}|${Date.now()}`, 'utf8')
329 .digest('hex')
330 .slice(0, 32);
331 return { status: 'completed', evidence_ref: `hash_${hash}`, cost_units: 1 };
332 }
333
334 /**
335 * @param {object[]} steps
336 * @returns {boolean}
337 */
338 export function stepHasForbiddenExternalTool(steps) {
339 for (const step of steps) {
340 if (!Array.isArray(step?.skill_refs)) continue;
341 for (const ref of step.skill_refs) {
342 if (ref && ref.kind === 'external_tool') return true;
343 }
344 }
345 return false;
346 }
347
348 /**
349 * @param {object} ctx
350 * @returns {{ ok: false, status: number, error: string, code: string }}
351 */
352 function refuse(status, code, error) {
353 return { ok: false, status, error, code };
354 }
355
356 /**
357 * @param {object} input
358 * @returns {{ visibleScopes: Set<FlowScope>, ambiguous: boolean }}
359 */
360 function resolveHandlerScopes(input) {
361 if (input.ambiguous === true) {
362 return { visibleScopes: new Set(['personal']), ambiguous: true };
363 }
364 if (input.visibleScopes instanceof Set) {
365 return { visibleScopes: input.visibleScopes, ambiguous: false };
366 }
367 return resolveFlowVisibleScopes({
368 dataDir: input.dataDir,
369 userId: input.userId,
370 vaultId: input.vaultId,
371 role: input.role,
372 cliScopes: input.cliScopes,
373 });
374 }
375
376 /**
377 * @param {object} vault
378 * @param {string} runId
379 * @param {Set<FlowScope>} visibleScopes
380 * @returns {object|null}
381 */
382 function findVisibleRun(vault, runId, visibleScopes) {
383 if (!vault || !Array.isArray(vault.runs)) return null;
384 const run = vault.runs.find((r) => r.run_id === runId);
385 if (!run) return null;
386 if (!visibleScopes.has(run.scope)) return null;
387 return run;
388 }
389
390 /**
391 * @param {object} vault
392 * @param {string} flowId
393 * @param {string} stepId
394 * @param {string} flowVersion
395 * @returns {object|null}
396 */
397 function findStepDefinition(vault, flowId, stepId, flowVersion) {
398 if (!vault || !Array.isArray(vault.steps)) return null;
399 return stepsForFlowVersion(vault, flowId, flowVersion).find((s) => s.step_id === stepId) ?? null;
400 }
401
402 /**
403 * @param {object} run
404 * @param {object} stepDef
405 * @param {object} stepState
406 * @returns {boolean}
407 */
408 function canMarkStepDone(stepDef, stepState) {
409 if (!stepDef?.verification?.evidence_required) return true;
410 return stepState.verified === true;
411 }
412
413 /**
414 * @param {object[]} stepStates
415 * @param {object[]} orderedSteps
416 * @returns {number}
417 */
418 export function frontierOrdinal(stepStates, orderedSteps) {
419 const stateById = new Map(stepStates.map((s) => [s.step_id, s]));
420 for (const step of orderedSteps) {
421 const state = stateById.get(step.step_id);
422 if (!state || state.status === 'pending' || state.status === 'in_progress') {
423 return step.ordinal;
424 }
425 if (state.status !== 'done' && state.status !== 'skipped') {
426 return step.ordinal;
427 }
428 }
429 return orderedSteps.length > 0 ? orderedSteps[orderedSteps.length - 1].ordinal + 1 : 1;
430 }
431
432 /**
433 * @param {object} input
434 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
435 */
436 export function handleFlowRunListRequest(input) {
437 const resolved = resolveHandlerScopes(input);
438 if (resolved.ambiguous) {
439 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
440 }
441
442 const flowId = typeof input.flowId === 'string' ? input.flowId.trim() : '';
443 if (flowId && !FLOW_ID_RE.test(flowId)) {
444 return refuse(400, 'BAD_REQUEST', 'Invalid flow id');
445 }
446
447 const store = loadFlowStore(input.dataDir);
448 const vault = store.vaults[input.vaultId];
449 const runs = vault && Array.isArray(vault.runs) ? vault.runs : [];
450 const filtered = runs.filter((r) => {
451 if (!resolved.visibleScopes.has(r.scope)) return false;
452 if (flowId && r.flow_id !== flowId) return false;
453 return true;
454 });
455
456 return {
457 ok: true,
458 payload: {
459 schema: FLOW_RUN_LIST_SCHEMA,
460 vault_id: input.vaultId,
461 runs: filtered.map(runForClient),
462 },
463 };
464 }
465
466 /**
467 * @param {object} input
468 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
469 */
470 export function handleFlowRunGetRequest(input) {
471 const runId = typeof input.runId === 'string' ? input.runId.trim() : '';
472 if (!runId || !FLOW_RUN_ID_RE.test(runId)) {
473 return refuse(400, 'BAD_REQUEST', 'Invalid run id');
474 }
475
476 const resolved = resolveHandlerScopes(input);
477 if (resolved.ambiguous) {
478 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
479 }
480
481 const store = loadFlowStore(input.dataDir);
482 const vault = store.vaults[input.vaultId];
483 const run = findVisibleRun(vault, runId, resolved.visibleScopes);
484 if (!run) {
485 return refuse(404, 'unknown_run', 'unknown_run');
486 }
487
488 return {
489 ok: true,
490 payload: {
491 schema: FLOW_RUN_SCHEMA,
492 vault_id: input.vaultId,
493 run: runForClient(run),
494 },
495 };
496 }
497
498 /**
499 * @param {object} input
500 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
501 */
502 export function handleFlowRunStartRequest(input) {
503 if (getFlowExecutionPolicyForbidden(input.dataDir)) {
504 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Execution forbidden by policy');
505 }
506 if (!getFlowRunWritesEnabled(input.dataDir)) {
507 return refuse(403, 'FLOW_RUN_WRITES_DISABLED', 'Run writes are disabled');
508 }
509
510 const flowId = typeof input.flowId === 'string' ? input.flowId.trim() : '';
511 const flowVersion = typeof input.flowVersion === 'string' ? input.flowVersion.trim() : '';
512 if (!flowId || !FLOW_ID_RE.test(flowId)) {
513 return refuse(400, 'BAD_REQUEST', 'Invalid flow id');
514 }
515 if (!flowVersion || !SEMVER_RE.test(flowVersion)) {
516 return refuse(400, 'BAD_REQUEST', 'Invalid flow version');
517 }
518
519 const resolved = resolveHandlerScopes(input);
520 if (resolved.ambiguous) {
521 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
522 }
523
524 const pinned = getFlow(input.dataDir, input.vaultId, flowId, {
525 filterScopes: resolved.visibleScopes,
526 version: flowVersion,
527 starterDir: input.starterDir,
528 });
529 if (!pinned) {
530 return refuse(404, 'unknown_flow', 'unknown_flow');
531 }
532
533 const store = loadFlowStore(input.dataDir);
534 if (!store.vaults[input.vaultId]) {
535 store.vaults[input.vaultId] = { flows: [], steps: [], runs: [], candidates: [], projections: [] };
536 }
537 const vault = store.vaults[input.vaultId];
538
539 const stepStates = pinned.steps.map((step) => ({
540 step_id: step.step_id,
541 status: 'pending',
542 evidence_ref: null,
543 verified: false,
544 }));
545
546 const runId = `run_${randomBytes(8).toString('hex')}`;
547 const actorHash = hashActorLabel(
548 typeof input.actorLabel === 'string' ? input.actorLabel : input.userId ?? 'actor',
549 input.vaultId,
550 input.userId ?? '',
551 );
552
553 /** @type {object} */
554 const run = {
555 schema: FLOW_RUN_SCHEMA,
556 run_id: runId,
557 flow_id: flowId,
558 flow_version: flowVersion,
559 scope: pinned.flow.scope,
560 status: 'in_progress',
561 step_states: stepStates,
562 started: new Date().toISOString(),
563 provenance: {
564 actor: actorHash,
565 harness: typeof input.harness === 'string' ? input.harness.trim() : 'hub',
566 },
567 task_ref: typeof input.taskRef === 'string' && input.taskRef.trim() ? input.taskRef.trim() : null,
568 external_ref:
569 typeof input.externalRef === 'string' && input.externalRef.trim() ? input.externalRef.trim() : null,
570 };
571
572 vault.runs.push(run);
573 saveFlowStore(input.dataDir, store);
574
575 return {
576 ok: true,
577 payload: {
578 schema: FLOW_RUN_START_SCHEMA,
579 run: runForClient(run),
580 },
581 };
582 }
583
584 /**
585 * @param {object} input
586 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
587 */
588 export function handleFlowRunAdvanceRequest(input) {
589 if (getFlowExecutionPolicyForbidden(input.dataDir)) {
590 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Execution forbidden by policy');
591 }
592 if (!getFlowRunWritesEnabled(input.dataDir)) {
593 return refuse(403, 'FLOW_RUN_WRITES_DISABLED', 'Run writes are disabled');
594 }
595
596 const runId = typeof input.runId === 'string' ? input.runId.trim() : '';
597 const stepId = typeof input.stepId === 'string' ? input.stepId.trim() : '';
598 const toStatus = typeof input.toStatus === 'string' ? input.toStatus.trim() : '';
599 const validStatuses = ['in_progress', 'blocked', 'done', 'skipped'];
600 if (!runId || !stepId || !validStatuses.includes(toStatus)) {
601 return refuse(400, 'BAD_REQUEST', 'Invalid advance request');
602 }
603
604 const resolved = resolveHandlerScopes(input);
605 if (resolved.ambiguous) {
606 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
607 }
608
609 const store = loadFlowStore(input.dataDir);
610 const vault = store.vaults[input.vaultId];
611 const runIdx = vault?.runs?.findIndex((r) => r.run_id === runId) ?? -1;
612 if (!vault || runIdx < 0) {
613 return refuse(404, 'unknown_run', 'unknown_run');
614 }
615 const run = vault.runs[runIdx];
616 if (!resolved.visibleScopes.has(run.scope)) {
617 return refuse(404, 'unknown_run', 'unknown_run');
618 }
619 if (run.status !== 'in_progress') {
620 return refuse(409, 'FLOW_RUN_NOT_IN_PROGRESS', 'Run is not in progress');
621 }
622
623 const pinned = getFlow(input.dataDir, input.vaultId, run.flow_id, {
624 filterScopes: resolved.visibleScopes,
625 version: run.flow_version,
626 starterDir: input.starterDir,
627 });
628 if (!pinned) {
629 return refuse(404, 'unknown_flow', 'unknown_flow');
630 }
631
632 const stepDef = findStepDefinition(vault, run.flow_id, stepId, run.flow_version);
633 if (!stepDef) {
634 return refuse(400, 'BAD_REQUEST', 'Unknown step');
635 }
636
637 const frontier = frontierOrdinal(run.step_states, pinned.steps);
638 if (stepDef.ordinal > frontier) {
639 return refuse(409, 'FLOW_STEP_OUT_OF_ORDER', 'Step out of order');
640 }
641
642 if (toStatus === 'skipped') {
643 const skipReason = typeof input.skipReason === 'string' ? input.skipReason.trim() : '';
644 if (!FLOW_SKIP_REASONS.includes(skipReason)) {
645 return refuse(400, 'BAD_REQUEST', 'skip_reason required for skipped status');
646 }
647 }
648
649 const stateIdx = run.step_states.findIndex((s) => s.step_id === stepId);
650 if (stateIdx < 0) {
651 return refuse(400, 'BAD_REQUEST', 'Step not in run');
652 }
653 const stepState = run.step_states[stateIdx];
654
655 if (toStatus === 'done' && !canMarkStepDone(stepDef, stepState)) {
656 return refuse(403, 'FLOW_VERIFICATION_UNSATISFIED', 'Verification unsatisfied');
657 }
658
659 if (toStatus === 'in_progress') {
660 for (const s of run.step_states) {
661 if (s.status === 'in_progress' && s.step_id !== stepId) {
662 return refuse(409, 'FLOW_STEP_OUT_OF_ORDER', 'Another step is in progress');
663 }
664 }
665 }
666
667 run.step_states[stateIdx] = {
668 ...stepState,
669 status: toStatus,
670 evidence_ref: stepState.evidence_ref ?? null,
671 verified: toStatus === 'done' ? stepState.verified === true : stepState.verified,
672 };
673
674 const allDone = run.step_states.every((s) => s.status === 'done' || s.status === 'skipped');
675 if (allDone) {
676 run.status = 'done';
677 }
678
679 vault.runs[runIdx] = run;
680 saveFlowStore(input.dataDir, store);
681
682 return {
683 ok: true,
684 payload: {
685 schema: FLOW_RUN_SCHEMA,
686 run: runForClient(run),
687 },
688 };
689 }
690
691 /**
692 * @param {object} input
693 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
694 */
695 export function handleFlowRunEvidenceRequest(input) {
696 if (getFlowExecutionPolicyForbidden(input.dataDir)) {
697 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Execution forbidden by policy');
698 }
699 if (!getFlowRunWritesEnabled(input.dataDir)) {
700 return refuse(403, 'FLOW_RUN_WRITES_DISABLED', 'Run writes are disabled');
701 }
702
703 const runId = typeof input.runId === 'string' ? input.runId.trim() : '';
704 const stepId = typeof input.stepId === 'string' ? input.stepId.trim() : '';
705 const evidenceRef = typeof input.evidenceRef === 'string' ? input.evidenceRef.trim() : '';
706 const pointerKind = typeof input.pointerKind === 'string' ? input.pointerKind.trim() : '';
707 const validKinds = ['proposal', 'artifact', 'hash', 'test_result'];
708 if (!runId || !stepId || !evidenceRef || !validKinds.includes(pointerKind)) {
709 return refuse(400, 'BAD_REQUEST', 'Invalid evidence request');
710 }
711
712 const resolved = resolveHandlerScopes(input);
713 if (resolved.ambiguous) {
714 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
715 }
716
717 const store = loadFlowStore(input.dataDir);
718 const vault = store.vaults[input.vaultId];
719 const runIdx = vault?.runs?.findIndex((r) => r.run_id === runId) ?? -1;
720 if (!vault || runIdx < 0) {
721 return refuse(404, 'unknown_run', 'unknown_run');
722 }
723 const run = vault.runs[runIdx];
724 if (!resolved.visibleScopes.has(run.scope)) {
725 return refuse(404, 'unknown_run', 'unknown_run');
726 }
727
728 const stepDef = findStepDefinition(vault, run.flow_id, stepId, run.flow_version);
729 if (!stepDef) {
730 return refuse(400, 'BAD_REQUEST', 'Unknown step');
731 }
732
733 const stateIdx = run.step_states.findIndex((s) => s.step_id === stepId);
734 if (stateIdx < 0) {
735 return refuse(400, 'BAD_REQUEST', 'Step not in run');
736 }
737
738 const verified =
739 stepDef.verification?.kind !== 'human_review' && stepDef.verification?.evidence_required === true;
740
741 run.step_states[stateIdx] = {
742 ...run.step_states[stateIdx],
743 evidence_ref: evidenceRef,
744 verified: verified ? true : run.step_states[stateIdx].verified,
745 };
746
747 vault.runs[runIdx] = run;
748 saveFlowStore(input.dataDir, store);
749
750 return {
751 ok: true,
752 payload: {
753 schema: FLOW_RUN_SCHEMA,
754 run: runForClient(run),
755 },
756 };
757 }
758
759 /**
760 * @param {object} input
761 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
762 */
763 export function handleFlowExecutionConsentMintRequest(input) {
764 if (getFlowExecutionPolicyForbidden(input.dataDir)) {
765 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Execution forbidden by policy');
766 }
767 if (!getFlowAutomatableExecutionEnabled(input.dataDir)) {
768 return refuse(403, 'FLOW_AUTOMATABLE_EXECUTION_DISABLED', 'Automatable execution is disabled');
769 }
770 if (!getFlowRunWritesEnabled(input.dataDir)) {
771 return refuse(403, 'FLOW_RUN_WRITES_DISABLED', 'Run writes are disabled');
772 }
773
774 const runId = typeof input.runId === 'string' ? input.runId.trim() : '';
775 if (!runId || !FLOW_RUN_ID_RE.test(runId)) {
776 return refuse(400, 'BAD_REQUEST', 'Invalid run id');
777 }
778
779 const allowedLanesRaw = input.allowedLanes;
780 if (!Array.isArray(allowedLanesRaw) || allowedLanesRaw.length === 0) {
781 return refuse(400, 'BAD_REQUEST', 'allowed_lanes must be non-empty');
782 }
783 const allowedLanes = [...new Set(allowedLanesRaw.map((l) => (typeof l === 'string' ? l.trim() : '')).filter(Boolean))];
784 if (allowedLanes.length === 0) {
785 return refuse(400, 'BAD_REQUEST', 'allowed_lanes must be non-empty');
786 }
787
788 let costCap = input.costCapUnits;
789 if (!Number.isInteger(costCap) || costCap < 1) {
790 return refuse(400, 'BAD_REQUEST', 'cost_cap_units must be a positive integer');
791 }
792
793 const vaultPolicy = readVaultExecutionPolicy(input.dataDir);
794 if (vaultPolicy.automatableForbidden) {
795 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Automatable steps forbidden');
796 }
797
798 for (const lane of allowedLanes) {
799 if (!vaultPolicy.allowedLanes.has(lane)) {
800 return refuse(403, 'FLOW_EXECUTION_LANE_DENIED', 'Lane not permitted');
801 }
802 }
803
804 if (costCap > vaultPolicy.defaultCostCapUnits) {
805 costCap = vaultPolicy.defaultCostCapUnits;
806 }
807
808 let ttlSeconds = input.ttlSeconds;
809 if (ttlSeconds !== undefined && ttlSeconds !== null) {
810 if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) {
811 return refuse(400, 'BAD_REQUEST', 'ttl_seconds must be a positive integer');
812 }
813 if (ttlSeconds > vaultPolicy.maxTtlSeconds) {
814 ttlSeconds = vaultPolicy.maxTtlSeconds;
815 }
816 } else {
817 ttlSeconds = vaultPolicy.defaultTtlSeconds;
818 }
819
820 const resolved = resolveHandlerScopes(input);
821 if (resolved.ambiguous) {
822 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
823 }
824
825 const store = loadFlowStore(input.dataDir);
826 const vault = store.vaults[input.vaultId];
827 const run = findVisibleRun(vault, runId, resolved.visibleScopes);
828 if (!run) {
829 return refuse(404, 'unknown_run', 'unknown_run');
830 }
831
832 const consentId = `${CONSENT_ID_PREFIX}${randomBytes(12).toString('hex')}`;
833 const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
834 const actorHash = hashActorLabel(
835 typeof input.actorLabel === 'string' ? input.actorLabel : input.userId ?? 'actor',
836 input.vaultId,
837 input.userId ?? '',
838 );
839
840 const consent = {
841 schema: FLOW_EXECUTION_CONSENT_SCHEMA,
842 consent_id: consentId,
843 vault_id: input.vaultId,
844 scope: run.scope,
845 run_id: runId,
846 flow_id: run.flow_id,
847 flow_version: run.flow_version,
848 allowed_lanes: allowedLanes,
849 cost_cap_units: costCap,
850 cost_consumed_units: 0,
851 actor_hash: actorHash,
852 expires_at: expiresAt,
853 revoked_at: null,
854 };
855
856 const consentStore = loadExecutionConsentsStore(input.dataDir);
857 if (!consentStore.vaults[input.vaultId]) {
858 consentStore.vaults[input.vaultId] = { consents: [] };
859 }
860 consentStore.vaults[input.vaultId].consents.push(consent);
861 saveExecutionConsentsStore(input.dataDir, consentStore);
862
863 return {
864 ok: true,
865 payload: {
866 schema: FLOW_EXECUTION_CONSENT_MINT_SCHEMA,
867 consent: consentForClient(consent),
868 },
869 };
870 }
871
872 /**
873 * @param {string} dataDir
874 * @param {string} vaultId
875 * @param {string} consentId
876 * @param {string} runId
877 * @returns {object|null}
878 */
879 export function findValidConsent(dataDir, vaultId, consentId, runId) {
880 const store = loadExecutionConsentsStore(dataDir);
881 const vault = store.vaults[vaultId];
882 if (!vault || !Array.isArray(vault.consents)) return null;
883 const consent = vault.consents.find((c) => c.consent_id === consentId);
884 if (!consent) return null;
885 if (consent.revoked_at) return null;
886 if (consent.run_id !== runId) return null;
887 if (Date.parse(consent.expires_at) <= Date.now()) return null;
888 return consent;
889 }
890
891 /**
892 * @param {object} input
893 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
894 */
895 export function handleFlowRunExecuteAutomatableRequest(input) {
896 if (getFlowExecutionPolicyForbidden(input.dataDir)) {
897 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Execution forbidden by policy');
898 }
899 if (!getFlowAutomatableExecutionEnabled(input.dataDir)) {
900 return refuse(403, 'FLOW_AUTOMATABLE_EXECUTION_DISABLED', 'Automatable execution is disabled');
901 }
902 if (!getFlowRunWritesEnabled(input.dataDir)) {
903 return refuse(403, 'FLOW_RUN_WRITES_DISABLED', 'Run writes are disabled');
904 }
905
906 const runId = typeof input.runId === 'string' ? input.runId.trim() : '';
907 const stepId = typeof input.stepId === 'string' ? input.stepId.trim() : '';
908 const consentId = typeof input.consentId === 'string' ? input.consentId.trim() : '';
909 if (!runId || !stepId || !consentId) {
910 return refuse(400, 'BAD_REQUEST', 'run_id, step_id, and consent_id are required');
911 }
912
913 const dryRun = input.dryRun === true;
914 const modelLane =
915 typeof input.modelLane === 'string' && input.modelLane.trim() ? input.modelLane.trim() : 'local_default';
916
917 const resolved = resolveHandlerScopes(input);
918 if (resolved.ambiguous) {
919 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
920 }
921
922 const vaultPolicy = readVaultExecutionPolicy(input.dataDir);
923 if (vaultPolicy.automatableForbidden) {
924 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Automatable steps forbidden');
925 }
926
927 const consent = findValidConsent(input.dataDir, input.vaultId, consentId, runId);
928 if (!consent) {
929 const store = loadExecutionConsentsStore(input.dataDir);
930 const vaultConsents = store.vaults[input.vaultId]?.consents ?? [];
931 const any = vaultConsents.find((c) => c.consent_id === consentId);
932 if (any && any.run_id !== runId) {
933 return refuse(403, 'FLOW_EXECUTION_CONSENT_RUN_MISMATCH', 'Consent run mismatch');
934 }
935 return refuse(403, 'FLOW_EXECUTION_CONSENT_REQUIRED', 'Valid consent required');
936 }
937
938 if (!consent.allowed_lanes.includes(modelLane)) {
939 return refuse(403, 'FLOW_EXECUTION_LANE_DENIED', 'Lane not in consent');
940 }
941
942 const inflightKey = inFlightExecutionKey(runId, stepId, consentId);
943 const inflightStore = loadInFlightExecutionsStore(input.dataDir);
944 const inflight = inflightStore.entries[inflightKey];
945 if (inflight && inflight.status === 'in_flight') {
946 return {
947 ok: true,
948 payload: {
949 schema: FLOW_EXECUTE_AUTOMATABLE_SCHEMA,
950 run: runForClient(inflight.run),
951 execution: inflight.execution,
952 },
953 };
954 }
955
956 const store = loadFlowStore(input.dataDir);
957 const vault = store.vaults[input.vaultId];
958 const runIdx = vault?.runs?.findIndex((r) => r.run_id === runId) ?? -1;
959 if (!vault || runIdx < 0) {
960 return refuse(404, 'unknown_run', 'unknown_run');
961 }
962 const run = vault.runs[runIdx];
963 if (!resolved.visibleScopes.has(run.scope)) {
964 return refuse(404, 'unknown_run', 'unknown_run');
965 }
966 if (run.status !== 'in_progress') {
967 return refuse(409, 'FLOW_RUN_NOT_IN_PROGRESS', 'Run is not in progress');
968 }
969
970 const stepDef = findStepDefinition(vault, run.flow_id, stepId, run.flow_version);
971 if (!stepDef) {
972 return refuse(400, 'BAD_REQUEST', 'Unknown step');
973 }
974 if (stepDef.automatable !== 'automatable') {
975 return refuse(400, 'FLOW_STEP_NOT_AUTOMATABLE', 'Step is not automatable');
976 }
977
978 if (stepDef.verification?.kind === 'human_review') {
979 return refuse(403, 'FLOW_VERIFICATION_UNSATISFIED', 'human_review cannot be auto-verified');
980 }
981
982 const pinned = getFlow(input.dataDir, input.vaultId, run.flow_id, {
983 filterScopes: resolved.visibleScopes,
984 version: run.flow_version,
985 starterDir: input.starterDir,
986 });
987 if (!pinned) {
988 return refuse(404, 'unknown_flow', 'unknown_flow');
989 }
990
991 const frontier = frontierOrdinal(run.step_states, pinned.steps);
992 const stateIdx = run.step_states.findIndex((s) => s.step_id === stepId);
993 if (stateIdx < 0 || stepDef.ordinal > frontier) {
994 return refuse(409, 'FLOW_STEP_OUT_OF_ORDER', 'Step out of order');
995 }
996
997 for (const ref of stepDef.skill_refs ?? []) {
998 if (ref.kind === 'external_tool') {
999 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'external_tool not allowed on execution path');
1000 }
1001 if (!AUTOMATABLE_SKILL_KINDS.has(ref.kind)) {
1002 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Skill ref kind not allowed');
1003 }
1004 }
1005
1006 const projectedCost = dryRun ? 0 : 1;
1007 if (consent.cost_consumed_units + projectedCost > consent.cost_cap_units) {
1008 return refuse(403, 'FLOW_EXECUTION_COST_CAPPED', 'Cost cap exceeded');
1009 }
1010
1011 const orchestration = runModelOrchestrationStub({ lane: modelLane, dryRun });
1012 const executionId = `${EXECUTION_ID_PREFIX}${randomBytes(12).toString('hex')}`;
1013 const completedAt = new Date().toISOString();
1014
1015 const execution = {
1016 execution_id: executionId,
1017 step_id: stepId,
1018 status: orchestration.status,
1019 evidence_ref: orchestration.evidence_ref,
1020 cost_units: orchestration.cost_units,
1021 model_lane: modelLane,
1022 completed_at: completedAt,
1023 };
1024
1025 if (!dryRun && orchestration.evidence_ref && stepDef.verification?.evidence_required) {
1026 run.step_states[stateIdx] = {
1027 ...run.step_states[stateIdx],
1028 evidence_ref: orchestration.evidence_ref,
1029 verified: stepDef.verification.kind !== 'human_review',
1030 status: run.step_states[stateIdx].status === 'pending' ? 'in_progress' : run.step_states[stateIdx].status,
1031 };
1032 }
1033
1034 if (!dryRun) {
1035 consent.cost_consumed_units += orchestration.cost_units;
1036 const consentStore = loadExecutionConsentsStore(input.dataDir);
1037 const consentVault = consentStore.vaults[input.vaultId];
1038 if (consentVault) {
1039 const cIdx = consentVault.consents.findIndex((c) => c.consent_id === consentId);
1040 if (cIdx >= 0) {
1041 consentVault.consents[cIdx] = { ...consentVault.consents[cIdx], cost_consumed_units: consent.cost_consumed_units };
1042 saveExecutionConsentsStore(input.dataDir, consentStore);
1043 }
1044 }
1045 vault.runs[runIdx] = run;
1046 saveFlowStore(input.dataDir, store);
1047 }
1048
1049 inflightStore.entries[inflightKey] = {
1050 status: 'in_flight',
1051 run,
1052 execution,
1053 };
1054 saveInFlightExecutionsStore(input.dataDir, inflightStore);
1055
1056 return {
1057 ok: true,
1058 payload: {
1059 schema: FLOW_EXECUTE_AUTOMATABLE_SCHEMA,
1060 run: runForClient(run),
1061 execution,
1062 },
1063 };
1064 }
1065
1066 /**
1067 * @param {object} input
1068 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
1069 */
1070 export function handleFlowRunSubmitReviewRequest(input) {
1071 if (getFlowExecutionPolicyForbidden(input.dataDir)) {
1072 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Execution forbidden by policy');
1073 }
1074 if (!getFlowRunWritesEnabled(input.dataDir)) {
1075 return refuse(403, 'FLOW_RUN_WRITES_DISABLED', 'Run writes are disabled');
1076 }
1077
1078 const runId = typeof input.runId === 'string' ? input.runId.trim() : '';
1079 const intent = typeof input.intent === 'string' ? input.intent.trim() : '';
1080 if (!runId || !intent) {
1081 return refuse(400, 'BAD_REQUEST', 'run_id and intent are required');
1082 }
1083
1084 if (typeof input.createProposal !== 'function') {
1085 return refuse(500, 'RUNTIME_ERROR', 'createProposal is required');
1086 }
1087
1088 const resolved = resolveHandlerScopes(input);
1089 if (resolved.ambiguous) {
1090 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
1091 }
1092
1093 const store = loadFlowStore(input.dataDir);
1094 const vault = store.vaults[input.vaultId];
1095 const run = findVisibleRun(vault, runId, resolved.visibleScopes);
1096 if (!run) {
1097 return refuse(404, 'unknown_run', 'unknown_run');
1098 }
1099
1100 const proposal = input.createProposal(input.dataDir, {
1101 intent,
1102 source: FLOW_PROPOSAL_SOURCE,
1103 review_queue: FLOW_REVIEW_QUEUE,
1104 external_ref: run.external_ref ?? undefined,
1105 body: JSON.stringify({ run_id: run.run_id, flow_id: run.flow_id, flow_version: run.flow_version }, null, 2),
1106 frontmatter: {
1107 type: 'flow_run_outcome',
1108 run_id: run.run_id,
1109 flow_id: run.flow_id,
1110 flow_version: run.flow_version,
1111 },
1112 });
1113
1114 return {
1115 ok: true,
1116 payload: {
1117 schema: FLOW_RUN_SCHEMA,
1118 run: runForClient(run),
1119 proposal_id: proposal.proposal_id,
1120 },
1121 };
1122 }
1123
1124 /**
1125 * MCP unified handler — action dispatch for flow_run tool.
1126 *
1127 * @param {object} input
1128 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
1129 */
1130 export function handleFlowRunMcpRequest(input) {
1131 const action = typeof input.action === 'string' ? input.action.trim() : '';
1132 switch (action) {
1133 case 'start':
1134 return handleFlowRunStartRequest({
1135 ...input,
1136 flowId: input.flowId ?? input.flow_id,
1137 flowVersion: input.flowVersion ?? input.flow_version,
1138 taskRef: input.taskRef ?? input.task_ref,
1139 externalRef: input.externalRef ?? input.external_ref,
1140 });
1141 case 'get':
1142 return handleFlowRunGetRequest({
1143 ...input,
1144 runId: input.runId ?? input.run_id,
1145 });
1146 case 'list':
1147 return handleFlowRunListRequest({
1148 ...input,
1149 flowId: input.flowId ?? input.flow_id,
1150 });
1151 case 'advance':
1152 return handleFlowRunAdvanceRequest({
1153 ...input,
1154 runId: input.runId ?? input.run_id,
1155 stepId: input.stepId ?? input.step_id,
1156 toStatus: input.toStatus ?? input.to_status,
1157 skipReason: input.skipReason ?? input.skip_reason,
1158 });
1159 case 'evidence':
1160 return handleFlowRunEvidenceRequest({
1161 ...input,
1162 runId: input.runId ?? input.run_id,
1163 stepId: input.stepId ?? input.step_id,
1164 evidenceRef: input.evidenceRef ?? input.evidence_ref,
1165 pointerKind: input.pointerKind ?? input.pointer_kind,
1166 });
1167 case 'execute_automatable':
1168 return handleFlowRunExecuteAutomatableRequest({
1169 ...input,
1170 runId: input.runId ?? input.run_id,
1171 stepId: input.stepId ?? input.step_id,
1172 consentId: input.consentId ?? input.consent_id,
1173 modelLane: input.modelLane ?? input.model_lane,
1174 dryRun: input.dryRun ?? input.dry_run,
1175 });
1176 case 'submit_review':
1177 return handleFlowRunSubmitReviewRequest({
1178 ...input,
1179 runId: input.runId ?? input.run_id,
1180 intent: input.intent,
1181 });
1182 case 'consent_mint':
1183 return handleFlowExecutionConsentMintRequest({
1184 ...input,
1185 runId: input.runId ?? input.run_id,
1186 allowedLanes: input.allowedLanes ?? input.allowed_lanes,
1187 costCapUnits: input.costCapUnits ?? input.cost_cap_units,
1188 ttlSeconds: input.ttlSeconds ?? input.ttl_seconds,
1189 });
1190 default:
1191 return refuse(400, 'BAD_REQUEST', 'Unknown flow_run action');
1192 }
1193 }
1194
1195 /**
1196 * @param {object[]} steps
1197 * @param {string} flowId
1198 * @param {string} [automatable]
1199 * @returns {object}
1200 */
1201 export function makeAutomatableFlowBundle(steps, flowId = 'flow_automatable_test', automatable = 'automatable') {
1202 const version = '1.0.0';
1203 const stepId = buildFlowStepId(flowId, 1);
1204 return {
1205 flow: {
1206 schema: 'knowtation.flow/v0',
1207 flow_id: flowId,
1208 title: 'Automatable test flow',
1209 version,
1210 scope: 'personal',
1211 summary: 'Flow with automatable step for execution gate tests.',
1212 tags: ['test'],
1213 steps: [stepId],
1214 inputs: [],
1215 vault_mirror_path: `meta/flows/${flowId.replace(/^flow_/, '')}.md`,
1216 updated: '2026-06-20T00:00:00Z',
1217 truncated: false,
1218 },
1219 steps: steps ?? [
1220 {
1221 schema: 'knowtation.flow_step/v0',
1222 step_id: stepId,
1223 flow_id: flowId,
1224 ordinal: 1,
1225 owned_job: 'Summarize notes',
1226 instruction: 'Summarize the weekly notes into a brief.',
1227 trigger: 'On request',
1228 when_not_to_run: 'When no notes exist',
1229 boundaries: ['Read only — untrusted text'],
1230 skill_refs: [{ kind: 'cli', id: 'knowtation search' }],
1231 output_shape: 'A short brief',
1232 verification: {
1233 kind: 'artifact_exists',
1234 evidence_required: true,
1235 description: 'Brief artifact exists',
1236 },
1237 automatable,
1238 },
1239 ],
1240 };
1241 }
File History 1 commit
sha256:cfe8c8cf68336f6d46318bd40610c18d9ff7df231df2fb190af1f5a9c4f4f93b fix(flow-store): versioned step keying for multi-version fl… Human minor 4 hours ago