flow-handlers.mjs
325 lines 8.4 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 13 hours ago
1 /**
2 * Shared Flow list/get handlers — CLI = MCP = Hub REST parity (Phase 7A-10b).
3 *
4 * @see docs/FLOW-STORE-CONTRACT-7A-10.md §7
5 */
6
7 import { listFlows, getFlow, FLOW_ID_RE, SEMVER_RE, MAX_FLOW_SUMMARIES } from './flow-store.mjs';
8 import { resolveFlowScopeQuery, resolveFlowVisibleScopes } from './flow-scope.mjs';
9 import {
10 HARNESS_VALUES,
11 isHarnessActive,
12 projectFlow,
13 renderedContentHash,
14 isProjectionStale,
15 flowProjectionForClient,
16 PROJECTION_GENERATOR_VERSION,
17 } from './projection-generator.mjs';
18 import {
19 isAgentBundleHarnessActive,
20 computeBundleAllowedTools,
21 collectFlowExternalToolRefs,
22 readVaultExternalAgentPolicy,
23 } from './external-agent.mjs';
24
25 /**
26 * @typedef {import('./flow-scope.mjs').FlowScope} FlowScope
27 */
28
29 /**
30 * @param {{ dataDir: string, vaultId: string, userId?: string, role?: string, cliScopes?: FlowScope[], ambiguous?: boolean }} ctx
31 * @returns {{ visibleScopes: Set<FlowScope>, ambiguous: boolean }}
32 */
33 export function resolveHandlerVisibleScopes(ctx) {
34 if (ctx.ambiguous === true) {
35 return { visibleScopes: new Set(['personal']), ambiguous: true };
36 }
37 if (ctx.visibleScopes instanceof Set) {
38 return { visibleScopes: ctx.visibleScopes, ambiguous: false };
39 }
40 return resolveFlowVisibleScopes({
41 dataDir: ctx.dataDir,
42 userId: ctx.userId,
43 vaultId: ctx.vaultId,
44 role: ctx.role,
45 cliScopes: ctx.cliScopes,
46 });
47 }
48
49 /**
50 * @param {{
51 * dataDir: string,
52 * vaultId: string,
53 * userId?: string,
54 * role?: string,
55 * cliScopes?: FlowScope[],
56 * visibleScopes?: Set<FlowScope>,
57 * ambiguous?: boolean,
58 * scope?: string,
59 * tag?: string,
60 * limit?: number,
61 * starterDir?: string,
62 * }} input
63 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
64 */
65 export function handleFlowListRequest(input) {
66 const resolved = resolveHandlerVisibleScopes(input);
67 if (resolved.ambiguous) {
68 return {
69 ok: false,
70 status: 400,
71 error: 'Ambiguous flow scope',
72 code: 'FLOW_SCOPE_AMBIGUOUS',
73 };
74 }
75
76 const scopeQuery = resolveFlowScopeQuery(resolved.visibleScopes, input.scope);
77 if (!scopeQuery.ok) {
78 return scopeQuery;
79 }
80
81 let limit = input.limit;
82 if (limit !== undefined && limit !== null) {
83 if (!Number.isInteger(limit) || limit < 1 || limit > MAX_FLOW_SUMMARIES) {
84 return {
85 ok: false,
86 status: 400,
87 error: `limit must be an integer between 1 and ${MAX_FLOW_SUMMARIES}`,
88 code: 'BAD_REQUEST',
89 };
90 }
91 }
92
93 const payload = listFlows(input.dataDir, input.vaultId, {
94 visibleScopes: resolved.visibleScopes,
95 filterScopes: scopeQuery.filterScopes,
96 effectiveScope: scopeQuery.effectiveScope,
97 tag: input.tag,
98 limit,
99 starterDir: input.starterDir,
100 });
101
102 return { ok: true, payload };
103 }
104
105 /**
106 * @param {{
107 * dataDir: string,
108 * vaultId: string,
109 * flowId: string,
110 * userId?: string,
111 * role?: string,
112 * cliScopes?: FlowScope[],
113 * visibleScopes?: Set<FlowScope>,
114 * ambiguous?: boolean,
115 * scope?: string,
116 * version?: string,
117 * starterDir?: string,
118 * }} input
119 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
120 */
121 export function handleFlowGetRequest(input) {
122 const flowId = typeof input.flowId === 'string' ? input.flowId.trim() : '';
123 if (!flowId || !FLOW_ID_RE.test(flowId)) {
124 return {
125 ok: false,
126 status: 400,
127 error: 'Invalid flow id',
128 code: 'BAD_REQUEST',
129 };
130 }
131
132 const version = typeof input.version === 'string' ? input.version.trim() : '';
133 if (version && !SEMVER_RE.test(version)) {
134 return {
135 ok: false,
136 status: 400,
137 error: 'Invalid version',
138 code: 'BAD_REQUEST',
139 };
140 }
141
142 const resolved = resolveHandlerVisibleScopes(input);
143 if (resolved.ambiguous) {
144 return {
145 ok: false,
146 status: 400,
147 error: 'Ambiguous flow scope',
148 code: 'FLOW_SCOPE_AMBIGUOUS',
149 };
150 }
151
152 const payload = getFlow(input.dataDir, input.vaultId, flowId, {
153 filterScopes: resolved.visibleScopes,
154 version: version || undefined,
155 starterDir: input.starterDir,
156 });
157
158 if (!payload) {
159 return {
160 ok: false,
161 status: 404,
162 error: 'unknown_flow',
163 code: 'unknown_flow',
164 };
165 }
166
167 return { ok: true, payload };
168 }
169
170 /**
171 * @param {{
172 * dataDir: string,
173 * vaultId: string,
174 * flowId: string,
175 * harness: string,
176 * userId?: string,
177 * role?: string,
178 * cliScopes?: FlowScope[],
179 * visibleScopes?: Set<FlowScope>,
180 * ambiguous?: boolean,
181 * version?: string,
182 * starterDir?: string,
183 * generatedAt?: string,
184 * }} input
185 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
186 */
187 export function handleFlowProjectRequest(input) {
188 const flowId = typeof input.flowId === 'string' ? input.flowId.trim() : '';
189 if (!flowId || !FLOW_ID_RE.test(flowId)) {
190 return {
191 ok: false,
192 status: 400,
193 error: 'Invalid flow id',
194 code: 'BAD_REQUEST',
195 };
196 }
197
198 const harness = typeof input.harness === 'string' ? input.harness.trim() : '';
199 if (!harness) {
200 return {
201 ok: false,
202 status: 400,
203 error: 'Missing harness',
204 code: 'BAD_REQUEST',
205 };
206 }
207 if (!HARNESS_VALUES.includes(/** @type {typeof HARNESS_VALUES[number]} */ (harness))) {
208 return {
209 ok: false,
210 status: 400,
211 error: 'Invalid harness',
212 code: 'BAD_REQUEST',
213 };
214 }
215 const agentBundleEnabled = isAgentBundleHarnessActive(input.dataDir);
216 if (!isHarnessActive(harness, { agentBundleEnabled })) {
217 return {
218 ok: false,
219 status: 400,
220 error: 'Harness not supported in v0',
221 code: 'FLOW_HARNESS_UNSUPPORTED',
222 };
223 }
224
225 const version = typeof input.version === 'string' ? input.version.trim() : '';
226 if (version && !SEMVER_RE.test(version)) {
227 return {
228 ok: false,
229 status: 400,
230 error: 'Invalid version',
231 code: 'BAD_REQUEST',
232 };
233 }
234
235 const resolved = resolveHandlerVisibleScopes(input);
236 if (resolved.ambiguous) {
237 return {
238 ok: false,
239 status: 400,
240 error: 'Ambiguous flow scope',
241 code: 'FLOW_SCOPE_AMBIGUOUS',
242 };
243 }
244
245 const pinnedPayload = getFlow(input.dataDir, input.vaultId, flowId, {
246 filterScopes: resolved.visibleScopes,
247 version: version || undefined,
248 starterDir: input.starterDir,
249 });
250
251 if (!pinnedPayload) {
252 return {
253 ok: false,
254 status: 404,
255 error: 'unknown_flow',
256 code: 'unknown_flow',
257 };
258 }
259
260 const latestPayload = getFlow(input.dataDir, input.vaultId, flowId, {
261 filterScopes: resolved.visibleScopes,
262 starterDir: input.starterDir,
263 });
264
265 const latestVersion = latestPayload?.flow?.version ?? pinnedPayload.flow.version;
266
267 let allowedTools = [];
268 if (harness === 'agent_bundle') {
269 const vaultPolicy = readVaultExternalAgentPolicy(input.dataDir);
270 const flowToolRefs = collectFlowExternalToolRefs(pinnedPayload.steps);
271 allowedTools = computeBundleAllowedTools(flowToolRefs, vaultPolicy.allowedTools);
272 }
273
274 const projection = projectFlow(pinnedPayload.flow, pinnedPayload.steps, {
275 harness: /** @type {import('./projection-generator.mjs').Harness} */ (harness),
276 allowedTools,
277 });
278
279 const generatedAt =
280 typeof input.generatedAt === 'string' && input.generatedAt.trim()
281 ? input.generatedAt.trim()
282 : new Date().toISOString();
283
284 const payload = {
285 schema: 'knowtation.flow_project/v0',
286 vault_id: input.vaultId,
287 projection: flowProjectionForClient(projection),
288 staleness: {
289 stale: isProjectionStale(projection.flow_version, latestVersion),
290 projection_version: projection.flow_version,
291 latest_version: latestVersion,
292 },
293 generator: {
294 generator_version: PROJECTION_GENERATOR_VERSION,
295 content_hash: renderedContentHash(projection.rendered),
296 generated_at: generatedAt,
297 },
298 };
299
300 return { ok: true, payload };
301 }
302
303 /**
304 * Serialize payload for byte-identical parity (stable key order via JSON.stringify).
305 *
306 * @param {object} payload
307 * @returns {string}
308 */
309 export function serializeFlowPayload(payload) {
310 return JSON.stringify(payload);
311 }
312
313 /**
314 * Strip envelope-only generated_at for cross-surface deep equality.
315 *
316 * @param {object} payload
317 * @returns {object}
318 */
319 export function stripFlowProjectGeneratedAt(payload) {
320 const copy = structuredClone(payload);
321 if (copy?.generator && typeof copy.generator === 'object') {
322 delete copy.generator.generated_at;
323 }
324 return copy;
325 }
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 13 hours ago