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