@hmj-ai/cflow 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +241 -0
- package/README.md +111 -0
- package/dist/public/assets/index-BqTfYp5s.js +15 -0
- package/dist/public/assets/index-D0BpmA_V.css +1 -0
- package/dist/public/index.html +15 -0
- package/dist/src/compiler.js +164 -0
- package/dist/src/contract.js +26 -0
- package/dist/src/db.js +208 -0
- package/dist/src/engine.js +487 -0
- package/dist/src/flow-agent.js +190 -0
- package/dist/src/hash.js +14 -0
- package/dist/src/proposal.js +595 -0
- package/dist/src/runtime-manifest.js +244 -0
- package/dist/src/runtime-process.js +175 -0
- package/dist/src/runtime.js +850 -0
- package/dist/src/server.js +652 -0
- package/dist/src/types.js +1 -0
- package/dist/src/workspace.js +26 -0
- package/package.json +68 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import Fastify from 'fastify';
|
|
3
|
+
import fastifyStatic from '@fastify/static';
|
|
4
|
+
import fastifyMultipart from '@fastify/multipart';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { constants, existsSync, mkdirSync, realpathSync } from 'node:fs';
|
|
7
|
+
import { cp, mkdir, readdir, realpath, rm, stat, access } from 'node:fs/promises';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { dirname, isAbsolute, join, sep } from 'node:path';
|
|
10
|
+
import { Store } from './db.js';
|
|
11
|
+
import { compileCF, compileFlow } from './compiler.js';
|
|
12
|
+
import { Engine, builtins, newRunId } from './engine.js';
|
|
13
|
+
import { RuntimeManager } from './runtime.js';
|
|
14
|
+
import { sha256 } from './hash.js';
|
|
15
|
+
import { requireWorkspaceRoot, validateWorkspaceRoot } from './workspace.js';
|
|
16
|
+
import { collectGroundingFailures, groundingError, assertAttachmentGrounding, applyFlowRevision, buildProposalGraph, flowProposalOutputSchema, flowProposalPrompt, matchPublishedCapabilities, prepareSkillAttachments, } from './proposal.js';
|
|
17
|
+
import { flowAgentContext, flowAgentFallback, flowAgentOutputSchema, flowAgentPrompt, normalizeAgentResponse, } from './flow-agent.js';
|
|
18
|
+
export function createApp(store = new Store()) {
|
|
19
|
+
const versions = () => store.list('cf_versions');
|
|
20
|
+
const flowCompilations = () => store.flowCompilations();
|
|
21
|
+
const runtimes = new RuntimeManager(store);
|
|
22
|
+
const testPlans = new Map();
|
|
23
|
+
const testCatalogs = new Map();
|
|
24
|
+
const executorRegistry = builtins();
|
|
25
|
+
runtimes.registerAll(executorRegistry);
|
|
26
|
+
const engine = new Engine(store, executorRegistry, () => [
|
|
27
|
+
...versions(),
|
|
28
|
+
...[...testCatalogs.values()].flat(),
|
|
29
|
+
]);
|
|
30
|
+
const storedFlowWorkspace = (flowId) => {
|
|
31
|
+
const draft = store.get('flow_drafts', flowId);
|
|
32
|
+
if (draft)
|
|
33
|
+
return requireWorkspaceRoot(draft.workspaceRoot);
|
|
34
|
+
const plan = store.list('flow_versions').find((value) => value.flowId === flowId);
|
|
35
|
+
return plan ? requireWorkspaceRoot(plan.workspaceRoot) : undefined;
|
|
36
|
+
};
|
|
37
|
+
const assertFlowWorkspaceImmutable = (draft) => {
|
|
38
|
+
const requested = requireWorkspaceRoot(draft.workspaceRoot);
|
|
39
|
+
const stored = storedFlowWorkspace(draft.flowId);
|
|
40
|
+
if (stored !== undefined && stored !== requested) {
|
|
41
|
+
try {
|
|
42
|
+
if (validateWorkspaceRoot(requested) !== stored)
|
|
43
|
+
throw new Error('FLOW_WORKSPACE_IMMUTABLE');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new Error('FLOW_WORKSPACE_IMMUTABLE');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return stored ?? requested;
|
|
50
|
+
};
|
|
51
|
+
const operationalWorkspace = (draft) => {
|
|
52
|
+
const requested = assertFlowWorkspaceImmutable(draft);
|
|
53
|
+
const normalized = validateWorkspaceRoot(requested);
|
|
54
|
+
if (normalized !== requested)
|
|
55
|
+
throw new Error('FLOW_WORKSPACE_IMMUTABLE');
|
|
56
|
+
return normalized;
|
|
57
|
+
};
|
|
58
|
+
const normalizeNewFlowWorkspace = (draft) => {
|
|
59
|
+
const stored = storedFlowWorkspace(draft.flowId);
|
|
60
|
+
if (stored) {
|
|
61
|
+
assertFlowWorkspaceImmutable(draft);
|
|
62
|
+
return draft;
|
|
63
|
+
}
|
|
64
|
+
return { ...draft, workspaceRoot: validateWorkspaceRoot(draft.workspaceRoot) };
|
|
65
|
+
};
|
|
66
|
+
const app = Fastify({ logger: true });
|
|
67
|
+
app.register(fastifyMultipart, {
|
|
68
|
+
// Do not silently truncate large skill bundles; files are streamed to disk.
|
|
69
|
+
// Keep only structural parser guards here, while runtime timeout/output limits
|
|
70
|
+
// remain the semantic protection for analysis requests.
|
|
71
|
+
limits: { fileSize: Number.MAX_SAFE_INTEGER, files: 10_000, parts: 10_000 },
|
|
72
|
+
});
|
|
73
|
+
const runtimePublicRoot = (() => {
|
|
74
|
+
const currentDir = fileURLToPath(new URL('.', import.meta.url));
|
|
75
|
+
if (currentDir.includes(`${sep}dist${sep}`))
|
|
76
|
+
return fileURLToPath(new URL('../public', import.meta.url));
|
|
77
|
+
const packaged = fileURLToPath(new URL('../dist/public/index.html', import.meta.url));
|
|
78
|
+
return existsSync(packaged)
|
|
79
|
+
? fileURLToPath(new URL('../dist/public', import.meta.url))
|
|
80
|
+
: fileURLToPath(new URL('../public', import.meta.url));
|
|
81
|
+
})();
|
|
82
|
+
const pinRuntimeProfiles = async (plan, catalog) => {
|
|
83
|
+
const nodes = await Promise.all(plan.nodes.map(async (node) => {
|
|
84
|
+
if (node.kind !== 'cf-call')
|
|
85
|
+
return node;
|
|
86
|
+
const version = catalog.find((item) => item.cfId === node.cfRef.cfId && item.version === node.cfRef.version);
|
|
87
|
+
const runtimeId = node.executor ?? version?.draft.defaultExecutor ?? runtimes.settings().defaultRuntimeId;
|
|
88
|
+
const profile = runtimes.profile(runtimeId);
|
|
89
|
+
if (!profile)
|
|
90
|
+
throw new Error(`RUNTIME_NOT_FOUND:${runtimeId}`);
|
|
91
|
+
const health = await runtimes.health(runtimeId);
|
|
92
|
+
if (health.status !== 'available')
|
|
93
|
+
throw new Error(`RUNTIME_UNAVAILABLE:${runtimeId}`);
|
|
94
|
+
return {
|
|
95
|
+
...node,
|
|
96
|
+
executor: runtimeId,
|
|
97
|
+
executorProfile: { id: runtimeId, profileVersion: profile.profileVersion },
|
|
98
|
+
};
|
|
99
|
+
}));
|
|
100
|
+
const { planHash: _oldHash, ...body } = plan;
|
|
101
|
+
const pinned = { ...body, nodes };
|
|
102
|
+
return { ...pinned, planHash: sha256(pinned) };
|
|
103
|
+
};
|
|
104
|
+
const applyCompileRuntime = (draft, runtimeId) => {
|
|
105
|
+
const selected = runtimeId?.trim();
|
|
106
|
+
if (!selected)
|
|
107
|
+
return draft;
|
|
108
|
+
return {
|
|
109
|
+
...draft,
|
|
110
|
+
nodes: draft.nodes.map((node) => node.kind === 'cf-call' && !node.executor ? { ...node, executor: selected } : node),
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
const saveCompilationSnapshot = (mode, flowDraft, plan, programs, runId) => {
|
|
114
|
+
const snapshot = {
|
|
115
|
+
id: `${flowDraft.flowId}@${flowDraft.revision}:${mode}`,
|
|
116
|
+
flowId: flowDraft.flowId,
|
|
117
|
+
flowRevision: flowDraft.revision,
|
|
118
|
+
mode,
|
|
119
|
+
flowDraft,
|
|
120
|
+
plan,
|
|
121
|
+
programs,
|
|
122
|
+
runId,
|
|
123
|
+
createdAt: new Date().toISOString(),
|
|
124
|
+
};
|
|
125
|
+
store.saveFlowCompilation(snapshot);
|
|
126
|
+
return snapshot;
|
|
127
|
+
};
|
|
128
|
+
const resolveResources = (plan, profileId) => {
|
|
129
|
+
const requirements = plan.resources ?? [];
|
|
130
|
+
const profile = profileId ? store.getResourceProfile(profileId) : undefined;
|
|
131
|
+
if (profileId && !profile)
|
|
132
|
+
throw new Error('RESOURCE_PROFILE_NOT_FOUND');
|
|
133
|
+
const resolved = [];
|
|
134
|
+
for (const binding of profile?.bindings ?? []) {
|
|
135
|
+
if (!requirements.some((requirement) => requirement.id === binding.requirementId))
|
|
136
|
+
throw new Error(`RESOURCE_BINDING_UNKNOWN_REQUIREMENT:${binding.requirementId}`);
|
|
137
|
+
}
|
|
138
|
+
for (const requirement of requirements) {
|
|
139
|
+
const matches = profile?.bindings.filter((binding) => binding.requirementId === requirement.id) ?? [];
|
|
140
|
+
if (matches.length > 1)
|
|
141
|
+
throw new Error(`RESOURCE_BINDING_DUPLICATE:${requirement.id}`);
|
|
142
|
+
const binding = matches[0];
|
|
143
|
+
if (!binding) {
|
|
144
|
+
if (requirement.required)
|
|
145
|
+
throw new Error(`RESOURCE_BINDING_REQUIRED:${requirement.id}`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (binding.type !== requirement.type)
|
|
149
|
+
throw new Error(`RESOURCE_TYPE_MISMATCH:${requirement.id}`);
|
|
150
|
+
if (!binding.resourceId.trim())
|
|
151
|
+
throw new Error(`RESOURCE_ID_REQUIRED:${requirement.id}`);
|
|
152
|
+
resolved.push({ ...binding, access: requirement.access, profileId: profile.id });
|
|
153
|
+
}
|
|
154
|
+
return resolved;
|
|
155
|
+
};
|
|
156
|
+
const persistFlowProposal = async (proposal, attachments) => {
|
|
157
|
+
if (!proposal.flowDraft) {
|
|
158
|
+
if (attachments)
|
|
159
|
+
await rm(attachments.root, { recursive: true, force: true });
|
|
160
|
+
return proposal;
|
|
161
|
+
}
|
|
162
|
+
const draft = normalizeNewFlowWorkspace(proposal.flowDraft);
|
|
163
|
+
let archivePath;
|
|
164
|
+
if (attachments) {
|
|
165
|
+
archivePath = join('.cflow', 'flows', draft.flowId, 'attachments');
|
|
166
|
+
const namespace = join(draft.workspaceRoot, '.cflow', 'flows', draft.flowId);
|
|
167
|
+
try {
|
|
168
|
+
await mkdir(dirname(join(draft.workspaceRoot, archivePath)), { recursive: true });
|
|
169
|
+
await cp(attachments.root, join(draft.workspaceRoot, archivePath), {
|
|
170
|
+
recursive: true,
|
|
171
|
+
force: false,
|
|
172
|
+
errorOnExist: true,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
await rm(namespace, { recursive: true, force: true });
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await rm(attachments.root, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
store.save('flow_drafts', draft.flowId, draft);
|
|
184
|
+
return {
|
|
185
|
+
...proposal,
|
|
186
|
+
flowDraft: draft,
|
|
187
|
+
...(proposal.attachmentSummary && archivePath
|
|
188
|
+
? { attachmentSummary: { ...proposal.attachmentSummary, archivePath } }
|
|
189
|
+
: {}),
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
app.register(fastifyStatic, { root: runtimePublicRoot });
|
|
193
|
+
app.get('/', async (_, reply) => reply.sendFile('index.html'));
|
|
194
|
+
app.get('/api/settings', async () => runtimes.settings());
|
|
195
|
+
app.put('/api/settings', async (req) => runtimes.updateSettings(req.body ?? {}));
|
|
196
|
+
app.get('/api/directories', async (req) => {
|
|
197
|
+
const requested = req.query.path?.trim() || homedir();
|
|
198
|
+
if (!isAbsolute(requested))
|
|
199
|
+
throw new Error('DIRECTORY_PATH_NOT_ABSOLUTE');
|
|
200
|
+
let path;
|
|
201
|
+
try {
|
|
202
|
+
path = await realpath(requested);
|
|
203
|
+
if (!(await stat(path)).isDirectory())
|
|
204
|
+
throw new Error('DIRECTORY_NOT_FOUND');
|
|
205
|
+
await access(path, constants.R_OK);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
throw new Error('DIRECTORY_NOT_READABLE');
|
|
209
|
+
}
|
|
210
|
+
const children = await readdir(path, { withFileTypes: true });
|
|
211
|
+
const directories = (await Promise.all(children.map(async (entry) => {
|
|
212
|
+
const candidate = join(path, entry.name);
|
|
213
|
+
try {
|
|
214
|
+
const normalized = await realpath(candidate);
|
|
215
|
+
if (!(await stat(normalized)).isDirectory())
|
|
216
|
+
return null;
|
|
217
|
+
await access(normalized, constants.R_OK);
|
|
218
|
+
// Flag dotfiles so the picker can hide developer directories by default.
|
|
219
|
+
return { name: entry.name, path: normalized, hidden: entry.name.startsWith('.') };
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
})))
|
|
225
|
+
.filter((entry) => Boolean(entry))
|
|
226
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
227
|
+
const parentCandidate = dirname(path);
|
|
228
|
+
return {
|
|
229
|
+
path,
|
|
230
|
+
parentPath: parentCandidate === path ? null : await realpath(parentCandidate),
|
|
231
|
+
directories,
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
app.post('/api/directories/validate', async (req) => ({
|
|
235
|
+
path: validateWorkspaceRoot(req.body?.path, 'DIRECTORY_NOT_READ_WRITE'),
|
|
236
|
+
}));
|
|
237
|
+
const runtimeCatalog = () => Promise.all(runtimes.profiles().map(async (profile) => ({
|
|
238
|
+
...profile,
|
|
239
|
+
health: await runtimes.health(profile.id),
|
|
240
|
+
})));
|
|
241
|
+
app.get('/api/runtimes', runtimeCatalog);
|
|
242
|
+
app.post('/api/runtimes/discover', async () => {
|
|
243
|
+
for (const profile of runtimes.discover())
|
|
244
|
+
runtimes.register(executorRegistry, profile);
|
|
245
|
+
return { runtimes: await runtimeCatalog(), warnings: runtimes.discoveryWarnings() };
|
|
246
|
+
});
|
|
247
|
+
app.get('/api/runtimes/:id', async (req, reply) => {
|
|
248
|
+
const profile = runtimes.profile(req.params.id);
|
|
249
|
+
if (!profile)
|
|
250
|
+
return reply.code(404).send({ error: 'RUNTIME_NOT_FOUND' });
|
|
251
|
+
return {
|
|
252
|
+
...profile,
|
|
253
|
+
health: await runtimes.health(profile.id),
|
|
254
|
+
history: store.runtimeProfileHistory(profile.id),
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
app.post('/api/runtimes', async (req) => {
|
|
258
|
+
const profile = runtimes.saveProfile(req.body);
|
|
259
|
+
runtimes.register(executorRegistry, profile);
|
|
260
|
+
return { ...profile, health: await runtimes.health(profile.id) };
|
|
261
|
+
});
|
|
262
|
+
app.post('/api/runtimes/:id/test', async (req, reply) => {
|
|
263
|
+
if (!runtimes.profile(req.params.id))
|
|
264
|
+
return reply.code(404).send({ error: 'RUNTIME_NOT_FOUND' });
|
|
265
|
+
return runtimes.health(req.params.id);
|
|
266
|
+
});
|
|
267
|
+
app.get('/api/cfs', async () => store.list('cf_versions'));
|
|
268
|
+
app.get('/api/cf-drafts', async () => store.list('cf_drafts'));
|
|
269
|
+
app.get('/api/flow-compilations', async () => flowCompilations());
|
|
270
|
+
app.put('/api/cf-drafts/:id', async (req) => {
|
|
271
|
+
if (req.params.id !== req.body.cfId)
|
|
272
|
+
throw new Error('CF_DRAFT_ID_MISMATCH');
|
|
273
|
+
if (!req.body.cfId?.trim() || !req.body.name?.trim() || !req.body.does?.trim())
|
|
274
|
+
throw new Error('CF_DRAFT_INVALID');
|
|
275
|
+
store.save('cf_drafts', req.body.cfId, req.body);
|
|
276
|
+
return req.body;
|
|
277
|
+
});
|
|
278
|
+
app.post('/api/cfs', async (req) => {
|
|
279
|
+
const v = compileCF(req.body);
|
|
280
|
+
store.save('cf_drafts', req.body.cfId, req.body);
|
|
281
|
+
store.save('cf_versions', `${v.cfId}@${v.version}`, v);
|
|
282
|
+
return v;
|
|
283
|
+
});
|
|
284
|
+
app.get('/api/flows', async () => store.list('flow_versions'));
|
|
285
|
+
app.get('/api/flow-drafts', async () => store.list('flow_drafts'));
|
|
286
|
+
app.put('/api/flow-drafts/:id', async (req) => {
|
|
287
|
+
if (req.params.id !== req.body.flowId)
|
|
288
|
+
throw new Error('FLOW_DRAFT_ID_MISMATCH');
|
|
289
|
+
if (!req.body.flowId?.trim() || !req.body.name?.trim() || !req.body.objective?.trim())
|
|
290
|
+
throw new Error('FLOW_DRAFT_INVALID');
|
|
291
|
+
const draft = normalizeNewFlowWorkspace(req.body);
|
|
292
|
+
store.save('flow_drafts', draft.flowId, draft);
|
|
293
|
+
return draft;
|
|
294
|
+
});
|
|
295
|
+
app.delete('/api/flow-drafts/:id', async (req, reply) => {
|
|
296
|
+
const result = store.db.prepare('DELETE FROM flow_drafts WHERE id=?').run(req.params.id);
|
|
297
|
+
if (!result.changes)
|
|
298
|
+
return reply.code(404).send({ error: 'FLOW_DRAFT_NOT_FOUND' });
|
|
299
|
+
return { deleted: true };
|
|
300
|
+
});
|
|
301
|
+
const deletePublishedFlow = async (id, reply) => {
|
|
302
|
+
const exact = store.deleteFlowVersion(id);
|
|
303
|
+
const result = exact.changes ? exact : store.deleteFlowVersions(id);
|
|
304
|
+
if (!result.changes)
|
|
305
|
+
return reply.code(404).send({ error: 'FLOW_VERSION_NOT_FOUND' });
|
|
306
|
+
return { deleted: true };
|
|
307
|
+
};
|
|
308
|
+
app.delete('/api/flows/:id', async (req, reply) => deletePublishedFlow(req.params.id, reply));
|
|
309
|
+
app.delete('/api/flows/:flowId/:flowVersion', async (req, reply) => deletePublishedFlow(`${req.params.flowId}@${req.params.flowVersion}`, reply));
|
|
310
|
+
app.post('/api/flow-compilations', async (req) => {
|
|
311
|
+
assertFlowWorkspaceImmutable(req.body.flowDraft);
|
|
312
|
+
const candidates = (req.body.cfDrafts ?? []).map(compileCF);
|
|
313
|
+
const catalog = new Map([...versions(), ...candidates].map((version) => [
|
|
314
|
+
`${version.cfId}@${version.version}`,
|
|
315
|
+
version,
|
|
316
|
+
]));
|
|
317
|
+
const selectedDraft = applyCompileRuntime(req.body.flowDraft, req.body.runtimeId);
|
|
318
|
+
const compiled = compileFlow(selectedDraft, catalog);
|
|
319
|
+
const plan = req.body.runtimeId
|
|
320
|
+
? await pinRuntimeProfiles(compiled, [...versions(), ...candidates])
|
|
321
|
+
: compiled;
|
|
322
|
+
saveCompilationSnapshot('preview', selectedDraft, plan, [...versions(), ...candidates].filter((version) => selectedDraft.nodes.some((node) => node.kind === 'cf-call' &&
|
|
323
|
+
node.cfRef.cfId === version.cfId &&
|
|
324
|
+
node.cfRef.version === version.version)));
|
|
325
|
+
const referenced = new Set(selectedDraft.nodes
|
|
326
|
+
.filter((node) => node.kind === 'cf-call')
|
|
327
|
+
.map((node) => `${node.cfRef.cfId}@${node.cfRef.version}`));
|
|
328
|
+
return {
|
|
329
|
+
plan,
|
|
330
|
+
programs: [...catalog.values()].filter((version) => referenced.has(`${version.cfId}@${version.version}`)),
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
app.post('/api/flow-proposals', async (req) => {
|
|
334
|
+
const multipart = typeof req.isMultipart === 'function' && req.isMultipart()
|
|
335
|
+
? await prepareSkillAttachments(req)
|
|
336
|
+
: null;
|
|
337
|
+
const discard = async () => {
|
|
338
|
+
if (multipart)
|
|
339
|
+
await rm(multipart.root, { recursive: true, force: true });
|
|
340
|
+
};
|
|
341
|
+
const failing = async (code) => {
|
|
342
|
+
await discard();
|
|
343
|
+
return new Error(code);
|
|
344
|
+
};
|
|
345
|
+
const body = multipart?.fields ?? req.body;
|
|
346
|
+
const workspaceRoot = validateWorkspaceRoot(body.workspaceRoot, 'FLOW_WORKSPACE_UNAVAILABLE');
|
|
347
|
+
const objective = String(body.objective ?? '').trim();
|
|
348
|
+
if (!objective)
|
|
349
|
+
throw await failing('OBJECTIVE_REQUIRED');
|
|
350
|
+
const catalog = versions();
|
|
351
|
+
const runtimeId = body.runtimeId?.trim();
|
|
352
|
+
const profile = runtimeId ? runtimes.profile(runtimeId) : undefined;
|
|
353
|
+
const runtimeUsable = Boolean(runtimeId) && profile?.backend !== 'builtin';
|
|
354
|
+
// Attachment analysis needs a real agent runtime; the keyword fallback
|
|
355
|
+
// below can only match already published capabilities.
|
|
356
|
+
if (multipart && !runtimeUsable)
|
|
357
|
+
throw await failing('RUNTIME_ANALYSIS_REQUIRED');
|
|
358
|
+
const summary = multipart
|
|
359
|
+
? {
|
|
360
|
+
attachmentSummary: {
|
|
361
|
+
fileCount: multipart.files.length,
|
|
362
|
+
skippedCount: multipart.skippedCount,
|
|
363
|
+
entryFiles: multipart.entryFiles,
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
: {};
|
|
367
|
+
const asProposal = (graph, name, assistantMessage, extra = {}) => persistFlowProposal({
|
|
368
|
+
objective,
|
|
369
|
+
...(runtimeUsable ? { runtimeId } : {}),
|
|
370
|
+
...(assistantMessage ? { assistantMessage } : {}),
|
|
371
|
+
cfDrafts: graph.cfDrafts,
|
|
372
|
+
flowDraft: {
|
|
373
|
+
flowId: `proposal-${Date.now()}`,
|
|
374
|
+
revision: 1,
|
|
375
|
+
name: name.slice(0, 80),
|
|
376
|
+
objective,
|
|
377
|
+
workspaceRoot,
|
|
378
|
+
nodes: graph.nodes,
|
|
379
|
+
edges: graph.edges,
|
|
380
|
+
},
|
|
381
|
+
unresolvedSuggestions: graph.cfDrafts.map((draft) => `PROPOSED_CF:${draft.cfId}`),
|
|
382
|
+
...summary,
|
|
383
|
+
...extra,
|
|
384
|
+
}, multipart);
|
|
385
|
+
if (runtimeUsable) {
|
|
386
|
+
if (!profile)
|
|
387
|
+
throw await failing('RUNTIME_NOT_FOUND');
|
|
388
|
+
const health = await runtimes.health(runtimeId);
|
|
389
|
+
if (health.status !== 'available')
|
|
390
|
+
throw await failing(`RUNTIME_UNAVAILABLE:${runtimeId}`);
|
|
391
|
+
const prompt = flowProposalPrompt(Boolean(multipart));
|
|
392
|
+
const input = {
|
|
393
|
+
objective,
|
|
394
|
+
...(multipart
|
|
395
|
+
? {
|
|
396
|
+
attachments: {
|
|
397
|
+
files: multipart.files,
|
|
398
|
+
entryFiles: multipart.entryFiles,
|
|
399
|
+
skippedCount: multipart.skippedCount,
|
|
400
|
+
contents: multipart.contents,
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
: {}),
|
|
404
|
+
catalog: catalog.slice(0, 80).map((version) => ({
|
|
405
|
+
cfId: version.cfId,
|
|
406
|
+
version: version.version,
|
|
407
|
+
name: version.draft.name,
|
|
408
|
+
does: version.draft.does,
|
|
409
|
+
})),
|
|
410
|
+
};
|
|
411
|
+
const deadline = AbortSignal.timeout(runtimes.settings().testTimeoutMs);
|
|
412
|
+
let response;
|
|
413
|
+
try {
|
|
414
|
+
response = multipart
|
|
415
|
+
? await runtimes.executeAnalysis(runtimeId, prompt, input, deadline, { cwd: multipart.root, allowedRoot: multipart.root }, flowProposalOutputSchema(true))
|
|
416
|
+
: await runtimes.execute(runtimeId, prompt, input, deadline, [], flowProposalOutputSchema(false), { workspaceRoot });
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
await discard();
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
const proposal = response;
|
|
423
|
+
if (!proposal || typeof proposal !== 'object' || !Array.isArray(proposal.stages))
|
|
424
|
+
throw await failing('RUNTIME_PROPOSAL_INVALID');
|
|
425
|
+
if (multipart) {
|
|
426
|
+
const failures = collectGroundingFailures(proposal, multipart.contents);
|
|
427
|
+
if (failures.length) {
|
|
428
|
+
req.log.warn({
|
|
429
|
+
attachmentFiles: multipart.files.length,
|
|
430
|
+
sourceChars: multipart.contents.reduce((sum, item) => sum + item.content.length, 0),
|
|
431
|
+
failures: failures.map((item) => ({
|
|
432
|
+
...item,
|
|
433
|
+
quote: item.quote.slice(0, 160),
|
|
434
|
+
})),
|
|
435
|
+
}, 'flow proposal rejected: stages not grounded in the uploaded source');
|
|
436
|
+
await discard();
|
|
437
|
+
throw groundingError(failures, proposal);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const graph = buildProposalGraph(proposal.stages, { catalog, runtimeId });
|
|
441
|
+
return asProposal(graph, String(proposal.flowName ?? objective), String(proposal.summary ?? 'Runtime 已生成可审阅的 Flow 草案。').slice(0, 1000));
|
|
442
|
+
}
|
|
443
|
+
const matched = matchPublishedCapabilities(objective, catalog);
|
|
444
|
+
if (!matched.length)
|
|
445
|
+
return persistFlowProposal({ objective, flowDraft: null, unresolvedSuggestions: ['NO_PUBLISHED_CF_MATCH'] }, multipart);
|
|
446
|
+
const graph = buildProposalGraph(matched.map((version) => ({
|
|
447
|
+
kind: 'cf-call',
|
|
448
|
+
name: version.draft.name,
|
|
449
|
+
does: version.draft.does,
|
|
450
|
+
cfId: version.cfId,
|
|
451
|
+
})), { catalog });
|
|
452
|
+
return asProposal(graph, objective);
|
|
453
|
+
});
|
|
454
|
+
app.post('/api/flow-agent/chat', async (req) => {
|
|
455
|
+
const message = req.body.message?.trim();
|
|
456
|
+
if (!message)
|
|
457
|
+
throw new Error('AGENT_MESSAGE_REQUIRED');
|
|
458
|
+
const catalog = versions();
|
|
459
|
+
const fallback = flowAgentFallback({ ...req.body, message }, catalog);
|
|
460
|
+
const runtimeId = req.body.runtimeId?.trim() || runtimes.settings().defaultRuntimeId;
|
|
461
|
+
const profile = runtimeId ? runtimes.profile(runtimeId) : undefined;
|
|
462
|
+
if (!profile || profile.backend === 'builtin')
|
|
463
|
+
return { ...fallback, fallback: true };
|
|
464
|
+
if (!req.body.flowDraft)
|
|
465
|
+
throw new Error('FLOW_WORKSPACE_REQUIRED');
|
|
466
|
+
const workspaceRoot = operationalWorkspace(req.body.flowDraft);
|
|
467
|
+
const health = await runtimes.health(runtimeId);
|
|
468
|
+
if (health.status !== 'available')
|
|
469
|
+
return { ...fallback, fallback: true };
|
|
470
|
+
const availableRuntimes = [];
|
|
471
|
+
for (const item of runtimes.profiles()) {
|
|
472
|
+
if (!item.enabled)
|
|
473
|
+
continue;
|
|
474
|
+
const itemHealth = item.id === runtimeId ? health : await runtimes.health(item.id);
|
|
475
|
+
if (itemHealth.status === 'available')
|
|
476
|
+
availableRuntimes.push({ id: item.id, name: item.name });
|
|
477
|
+
}
|
|
478
|
+
const grounded = Boolean(req.body.attachments?.length);
|
|
479
|
+
const response = await runtimes.execute(runtimeId, flowAgentPrompt(message, grounded), flowAgentContext({ ...req.body, message }, availableRuntimes, catalog), AbortSignal.timeout(runtimes.settings().testTimeoutMs), [], flowAgentOutputSchema(grounded), { workspaceRoot });
|
|
480
|
+
const normalized = normalizeAgentResponse(response);
|
|
481
|
+
if (normalized.intent === 'answer')
|
|
482
|
+
return { ...normalized, runtimeId };
|
|
483
|
+
if (grounded)
|
|
484
|
+
assertAttachmentGrounding(normalized, req.body.attachments ?? []);
|
|
485
|
+
const revised = applyFlowRevision(req.body.flowDraft, req.body.cfDrafts ?? [], normalized.stages, { catalog, runtimeId });
|
|
486
|
+
store.db.transaction(() => {
|
|
487
|
+
for (const draft of revised.cfDrafts)
|
|
488
|
+
store.save('cf_drafts', draft.cfId, draft);
|
|
489
|
+
store.save('flow_drafts', revised.flowDraft.flowId, revised.flowDraft);
|
|
490
|
+
})();
|
|
491
|
+
return { ...normalized, ...revised, runtimeId };
|
|
492
|
+
});
|
|
493
|
+
app.get('/api/runs', async () => store.runs());
|
|
494
|
+
app.get('/api/resources', async () => store.resourceProfiles());
|
|
495
|
+
app.post('/api/resources', async (req) => {
|
|
496
|
+
if (!req.body.id?.trim() || !req.body.name?.trim() || !Array.isArray(req.body.bindings))
|
|
497
|
+
throw new Error('RESOURCE_PROFILE_INVALID');
|
|
498
|
+
const ids = new Set();
|
|
499
|
+
for (const binding of req.body.bindings) {
|
|
500
|
+
if (!binding.requirementId?.trim() || !binding.resourceId?.trim() || !binding.type?.trim())
|
|
501
|
+
throw new Error('RESOURCE_BINDING_INVALID');
|
|
502
|
+
if (ids.has(binding.requirementId))
|
|
503
|
+
throw new Error(`RESOURCE_BINDING_DUPLICATE:${binding.requirementId}`);
|
|
504
|
+
ids.add(binding.requirementId);
|
|
505
|
+
}
|
|
506
|
+
store.saveResourceProfile(req.body.id, req.body);
|
|
507
|
+
return req.body;
|
|
508
|
+
});
|
|
509
|
+
app.delete('/api/resources/:id', async (req, reply) => {
|
|
510
|
+
const result = store.deleteResourceProfile(req.params.id);
|
|
511
|
+
if (!result.changes)
|
|
512
|
+
return reply.code(404).send({ error: 'RESOURCE_PROFILE_NOT_FOUND' });
|
|
513
|
+
const settings = runtimes.settings();
|
|
514
|
+
if (settings.defaultResourceProfileId === req.params.id)
|
|
515
|
+
runtimes.updateSettings({ defaultResourceProfileId: undefined });
|
|
516
|
+
return { deleted: true };
|
|
517
|
+
});
|
|
518
|
+
app.post('/api/flows', async (req) => {
|
|
519
|
+
const draft = normalizeNewFlowWorkspace(req.body);
|
|
520
|
+
operationalWorkspace(draft);
|
|
521
|
+
const catalog = versions();
|
|
522
|
+
const plan = await pinRuntimeProfiles(compileFlow(draft, new Map(catalog.map((v) => [`${v.cfId}@${v.version}`, v]))), catalog);
|
|
523
|
+
store.save('flow_drafts', draft.flowId, draft);
|
|
524
|
+
store.save('flow_versions', `${plan.flowId}@${plan.flowVersion}`, plan);
|
|
525
|
+
return plan;
|
|
526
|
+
});
|
|
527
|
+
app.post('/api/flow-tests', async (req) => {
|
|
528
|
+
operationalWorkspace(req.body.flowDraft);
|
|
529
|
+
const candidateVersions = (req.body.cfDrafts ?? []).map(compileCF);
|
|
530
|
+
const catalog = new Map([...versions(), ...candidateVersions].map((version) => [
|
|
531
|
+
`${version.cfId}@${version.version}`,
|
|
532
|
+
version,
|
|
533
|
+
]));
|
|
534
|
+
const selectedDraft = applyCompileRuntime(req.body.flowDraft, req.body.runtimeId);
|
|
535
|
+
store.save('flow_drafts', req.body.flowDraft.flowId, req.body.flowDraft);
|
|
536
|
+
const compiled = compileFlow(selectedDraft, catalog);
|
|
537
|
+
const plan = await pinRuntimeProfiles(compiled, [...versions(), ...candidateVersions]);
|
|
538
|
+
const programs = [...versions(), ...candidateVersions].filter((version) => selectedDraft.nodes.some((node) => node.kind === 'cf-call' &&
|
|
539
|
+
node.cfRef.cfId === version.cfId &&
|
|
540
|
+
node.cfRef.version === version.version));
|
|
541
|
+
const resources = resolveResources(plan, req.body.resourceProfileId);
|
|
542
|
+
const id = newRunId();
|
|
543
|
+
const flowVersionId = `test:${id}`;
|
|
544
|
+
testPlans.set(flowVersionId, plan);
|
|
545
|
+
testCatalogs.set(flowVersionId, candidateVersions);
|
|
546
|
+
saveCompilationSnapshot('test', selectedDraft, plan, programs, id);
|
|
547
|
+
store.createRun(id, flowVersionId, req.body.input ?? {}, resources, req.body.resourceProfileId);
|
|
548
|
+
engine.start(id, plan);
|
|
549
|
+
return {
|
|
550
|
+
runId: id,
|
|
551
|
+
test: true,
|
|
552
|
+
plan,
|
|
553
|
+
programs,
|
|
554
|
+
};
|
|
555
|
+
});
|
|
556
|
+
app.post('/api/runs', async (req) => {
|
|
557
|
+
const plan = store.get('flow_versions', `${req.body.flowId}@${req.body.flowVersion}`);
|
|
558
|
+
if (!plan)
|
|
559
|
+
throw new Error('FLOW_VERSION_NOT_FOUND');
|
|
560
|
+
validateWorkspaceRoot(plan.workspaceRoot);
|
|
561
|
+
const resources = resolveResources(plan, req.body.resourceProfileId);
|
|
562
|
+
const id = newRunId();
|
|
563
|
+
store.createRun(id, `${plan.flowId}@${plan.flowVersion}`, req.body.input ?? {}, resources, req.body.resourceProfileId);
|
|
564
|
+
engine.start(id, plan);
|
|
565
|
+
return { runId: id };
|
|
566
|
+
});
|
|
567
|
+
app.post('/api/runs/:id/cancel', async (req, reply) => {
|
|
568
|
+
if (!engine.cancel(req.params.id))
|
|
569
|
+
return reply.code(409).send({ error: 'RUN_NOT_RUNNING' });
|
|
570
|
+
return { cancelled: true };
|
|
571
|
+
});
|
|
572
|
+
app.post('/api/runs/:id/approvals/:node', async (req, reply) => {
|
|
573
|
+
if (!['approved', 'rejected'].includes(req.body.decision))
|
|
574
|
+
return reply.code(400).send({ error: 'APPROVAL_DECISION_INVALID' });
|
|
575
|
+
const run = store.getRun(req.params.id);
|
|
576
|
+
if (!run)
|
|
577
|
+
return reply.code(404).send({ error: 'RUN_NOT_FOUND' });
|
|
578
|
+
const node = Number(req.params.node);
|
|
579
|
+
const plan = testPlans.get(run.flow_version_id) ??
|
|
580
|
+
store.get('flow_versions', run.flow_version_id);
|
|
581
|
+
if (!Number.isInteger(node) ||
|
|
582
|
+
!plan?.nodes.some((item) => item.index === node && item.kind === 'approval')) {
|
|
583
|
+
return reply.code(400).send({ error: 'APPROVAL_NODE_INVALID' });
|
|
584
|
+
}
|
|
585
|
+
if (run.status !== 'waiting-approval')
|
|
586
|
+
return reply.code(409).send({ error: 'RUN_NOT_WAITING_APPROVAL' });
|
|
587
|
+
store.decideApproval(req.params.id, node, req.body.decision);
|
|
588
|
+
if (plan)
|
|
589
|
+
engine.start(req.params.id, plan);
|
|
590
|
+
return { runId: req.params.id, node, decision: req.body.decision };
|
|
591
|
+
});
|
|
592
|
+
app.get('/api/runs/:id', async (req, reply) => {
|
|
593
|
+
const run = store.getRun(req.params.id);
|
|
594
|
+
if (!run)
|
|
595
|
+
return reply.code(404).send({ error: 'RUN_NOT_FOUND' });
|
|
596
|
+
return { run, events: store.events(req.params.id) };
|
|
597
|
+
});
|
|
598
|
+
app.get('/api/runs/:id/events', async (req, reply) => {
|
|
599
|
+
reply.raw.writeHead(200, {
|
|
600
|
+
'content-type': 'text/event-stream',
|
|
601
|
+
'cache-control': 'no-cache',
|
|
602
|
+
connection: 'keep-alive',
|
|
603
|
+
});
|
|
604
|
+
let sent = Number(req.headers['last-event-id'] ?? 0);
|
|
605
|
+
const timer = setInterval(() => {
|
|
606
|
+
const events = store.events(req.params.id);
|
|
607
|
+
for (const e of events.filter((event) => event.seq > sent)) {
|
|
608
|
+
reply.raw.write(`id: ${e.seq}\ndata: ${JSON.stringify(e)}\n\n`);
|
|
609
|
+
sent = e.seq;
|
|
610
|
+
}
|
|
611
|
+
const run = store.getRun(req.params.id);
|
|
612
|
+
if (run && ['completed', 'failed', 'cancelled'].includes(run.status)) {
|
|
613
|
+
clearInterval(timer);
|
|
614
|
+
reply.raw.end();
|
|
615
|
+
}
|
|
616
|
+
}, 100);
|
|
617
|
+
req.raw.on('close', () => clearInterval(timer));
|
|
618
|
+
});
|
|
619
|
+
app.setErrorHandler((e, _r, reply) => reply.code(400).send({ error: e instanceof Error ? e.message : String(e) }));
|
|
620
|
+
const recover = setInterval(() => {
|
|
621
|
+
const job = store.claimJob();
|
|
622
|
+
if (!job)
|
|
623
|
+
return;
|
|
624
|
+
const run = store.getRun(job.runId);
|
|
625
|
+
if (!run)
|
|
626
|
+
return;
|
|
627
|
+
const plan = testPlans.get(run.flow_version_id) ??
|
|
628
|
+
store.get('flow_versions', run.flow_version_id);
|
|
629
|
+
if (plan)
|
|
630
|
+
engine.start(job.runId, plan);
|
|
631
|
+
}, 250);
|
|
632
|
+
app.addHook('onClose', async () => clearInterval(recover));
|
|
633
|
+
return app;
|
|
634
|
+
}
|
|
635
|
+
export const isDirectExecution = (entryPath = process.argv[1]) => {
|
|
636
|
+
if (!entryPath)
|
|
637
|
+
return false;
|
|
638
|
+
try {
|
|
639
|
+
return realpathSync(entryPath) === realpathSync(fileURLToPath(import.meta.url));
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
if (isDirectExecution()) {
|
|
646
|
+
mkdirSync('./data', { recursive: true });
|
|
647
|
+
const app = createApp();
|
|
648
|
+
await app.listen({
|
|
649
|
+
host: process.env.HOST ?? '127.0.0.1',
|
|
650
|
+
port: Number(process.env.PORT ?? 3000),
|
|
651
|
+
});
|
|
652
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { accessSync, constants, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
+
export function isWithinDirectory(root, candidate) {
|
|
4
|
+
const path = relative(resolve(root), resolve(candidate));
|
|
5
|
+
return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
|
6
|
+
}
|
|
7
|
+
export function validateWorkspaceRoot(value, errorCode = 'FLOW_WORKSPACE_UNAVAILABLE') {
|
|
8
|
+
if (typeof value !== 'string' || !isAbsolute(value))
|
|
9
|
+
throw new Error(errorCode);
|
|
10
|
+
let normalized;
|
|
11
|
+
try {
|
|
12
|
+
normalized = realpathSync(value);
|
|
13
|
+
if (!statSync(normalized).isDirectory())
|
|
14
|
+
throw new Error(errorCode);
|
|
15
|
+
accessSync(normalized, constants.R_OK | constants.W_OK);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error(errorCode);
|
|
19
|
+
}
|
|
20
|
+
return normalized;
|
|
21
|
+
}
|
|
22
|
+
export function requireWorkspaceRoot(value) {
|
|
23
|
+
if (typeof value !== 'string' || !value.trim() || !isAbsolute(value))
|
|
24
|
+
throw new Error('FLOW_WORKSPACE_REQUIRED');
|
|
25
|
+
return value;
|
|
26
|
+
}
|