@hmj-ai/cflow 1.1.0 → 1.2.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.
@@ -3,22 +3,22 @@ import Fastify from 'fastify';
3
3
  import fastifyStatic from '@fastify/static';
4
4
  import fastifyMultipart from '@fastify/multipart';
5
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';
6
+ import { existsSync, realpathSync } from 'node:fs';
7
+ import { cp, mkdir, rm } from 'node:fs/promises';
8
+ import { dirname, join, sep } from 'node:path';
10
9
  import { Store } from './db.js';
11
10
  import { compileCF, compileFlow } from './compiler.js';
12
11
  import { Engine, builtins, newRunId } from './engine.js';
13
12
  import { RuntimeManager } from './runtime.js';
14
13
  import { sha256 } from './hash.js';
15
- import { requireWorkspaceRoot, validateWorkspaceRoot } from './workspace.js';
14
+ import { initializeWorkspace, validateWorkspaceRoot } from './workspace.js';
16
15
  import { collectGroundingFailures, groundingError, assertAttachmentGrounding, applyFlowRevision, buildProposalGraph, flowProposalOutputSchema, flowProposalPrompt, matchPublishedCapabilities, prepareSkillAttachments, } from './proposal.js';
17
16
  import { flowAgentContext, flowAgentFallback, flowAgentOutputSchema, flowAgentPrompt, normalizeAgentResponse, } from './flow-agent.js';
18
- export function createApp(store = new Store()) {
17
+ export function createApp(store = new Store(), requestedWorkspaceRoot = process.cwd()) {
18
+ const workspaceRoot = validateWorkspaceRoot(requestedWorkspaceRoot, 'WORKSPACE_UNAVAILABLE');
19
19
  const versions = () => store.list('cf_versions');
20
20
  const flowCompilations = () => store.flowCompilations();
21
- const runtimes = new RuntimeManager(store);
21
+ const runtimes = new RuntimeManager(store, { projectRoot: workspaceRoot });
22
22
  const testPlans = new Map();
23
23
  const testCatalogs = new Map();
24
24
  const executorRegistry = builtins();
@@ -27,42 +27,7 @@ export function createApp(store = new Store()) {
27
27
  ...versions(),
28
28
  ...[...testCatalogs.values()].flat(),
29
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
- };
30
+ const scopeFlowDraft = (draft) => ({ ...draft, workspaceRoot });
66
31
  const app = Fastify({ logger: true });
67
32
  app.register(fastifyMultipart, {
68
33
  // Do not silently truncate large skill bundles; files are streamed to disk.
@@ -159,7 +124,7 @@ export function createApp(store = new Store()) {
159
124
  await rm(attachments.root, { recursive: true, force: true });
160
125
  return proposal;
161
126
  }
162
- const draft = normalizeNewFlowWorkspace(proposal.flowDraft);
127
+ const draft = scopeFlowDraft(proposal.flowDraft);
163
128
  let archivePath;
164
129
  if (attachments) {
165
130
  archivePath = join('.cflow', 'flows', draft.flowId, 'attachments');
@@ -191,49 +156,9 @@ export function createApp(store = new Store()) {
191
156
  };
192
157
  app.register(fastifyStatic, { root: runtimePublicRoot });
193
158
  app.get('/', async (_, reply) => reply.sendFile('index.html'));
159
+ app.get('/api/workspace', async () => ({ root: workspaceRoot }));
194
160
  app.get('/api/settings', async () => runtimes.settings());
195
161
  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
162
  const runtimeCatalog = () => Promise.all(runtimes.profiles().map(async (profile) => ({
238
163
  ...profile,
239
164
  health: await runtimes.health(profile.id),
@@ -288,7 +213,7 @@ export function createApp(store = new Store()) {
288
213
  throw new Error('FLOW_DRAFT_ID_MISMATCH');
289
214
  if (!req.body.flowId?.trim() || !req.body.name?.trim() || !req.body.objective?.trim())
290
215
  throw new Error('FLOW_DRAFT_INVALID');
291
- const draft = normalizeNewFlowWorkspace(req.body);
216
+ const draft = scopeFlowDraft(req.body);
292
217
  store.save('flow_drafts', draft.flowId, draft);
293
218
  return draft;
294
219
  });
@@ -308,13 +233,13 @@ export function createApp(store = new Store()) {
308
233
  app.delete('/api/flows/:id', async (req, reply) => deletePublishedFlow(req.params.id, reply));
309
234
  app.delete('/api/flows/:flowId/:flowVersion', async (req, reply) => deletePublishedFlow(`${req.params.flowId}@${req.params.flowVersion}`, reply));
310
235
  app.post('/api/flow-compilations', async (req) => {
311
- assertFlowWorkspaceImmutable(req.body.flowDraft);
236
+ const scopedDraft = scopeFlowDraft(req.body.flowDraft);
312
237
  const candidates = (req.body.cfDrafts ?? []).map(compileCF);
313
238
  const catalog = new Map([...versions(), ...candidates].map((version) => [
314
239
  `${version.cfId}@${version.version}`,
315
240
  version,
316
241
  ]));
317
- const selectedDraft = applyCompileRuntime(req.body.flowDraft, req.body.runtimeId);
242
+ const selectedDraft = applyCompileRuntime(scopedDraft, req.body.runtimeId);
318
243
  const compiled = compileFlow(selectedDraft, catalog);
319
244
  const plan = req.body.runtimeId
320
245
  ? await pinRuntimeProfiles(compiled, [...versions(), ...candidates])
@@ -343,7 +268,6 @@ export function createApp(store = new Store()) {
343
268
  return new Error(code);
344
269
  };
345
270
  const body = multipart?.fields ?? req.body;
346
- const workspaceRoot = validateWorkspaceRoot(body.workspaceRoot, 'FLOW_WORKSPACE_UNAVAILABLE');
347
271
  const objective = String(body.objective ?? '').trim();
348
272
  if (!objective)
349
273
  throw await failing('OBJECTIVE_REQUIRED');
@@ -455,15 +379,18 @@ export function createApp(store = new Store()) {
455
379
  const message = req.body.message?.trim();
456
380
  if (!message)
457
381
  throw new Error('AGENT_MESSAGE_REQUIRED');
382
+ const request = {
383
+ ...req.body,
384
+ flowDraft: req.body.flowDraft ? scopeFlowDraft(req.body.flowDraft) : null,
385
+ };
458
386
  const catalog = versions();
459
- const fallback = flowAgentFallback({ ...req.body, message }, catalog);
460
- const runtimeId = req.body.runtimeId?.trim() || runtimes.settings().defaultRuntimeId;
387
+ const fallback = flowAgentFallback({ ...request, message }, catalog);
388
+ const runtimeId = request.runtimeId?.trim() || runtimes.settings().defaultRuntimeId;
461
389
  const profile = runtimeId ? runtimes.profile(runtimeId) : undefined;
462
390
  if (!profile || profile.backend === 'builtin')
463
391
  return { ...fallback, fallback: true };
464
- if (!req.body.flowDraft)
392
+ if (!request.flowDraft)
465
393
  throw new Error('FLOW_WORKSPACE_REQUIRED');
466
- const workspaceRoot = operationalWorkspace(req.body.flowDraft);
467
394
  const health = await runtimes.health(runtimeId);
468
395
  if (health.status !== 'available')
469
396
  return { ...fallback, fallback: true };
@@ -476,13 +403,13 @@ export function createApp(store = new Store()) {
476
403
  availableRuntimes.push({ id: item.id, name: item.name });
477
404
  }
478
405
  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 });
406
+ const response = await runtimes.execute(runtimeId, flowAgentPrompt(message, grounded), flowAgentContext({ ...request, message }, availableRuntimes, catalog), AbortSignal.timeout(runtimes.settings().testTimeoutMs), [], flowAgentOutputSchema(grounded), { workspaceRoot });
480
407
  const normalized = normalizeAgentResponse(response);
481
408
  if (normalized.intent === 'answer')
482
409
  return { ...normalized, runtimeId };
483
410
  if (grounded)
484
411
  assertAttachmentGrounding(normalized, req.body.attachments ?? []);
485
- const revised = applyFlowRevision(req.body.flowDraft, req.body.cfDrafts ?? [], normalized.stages, { catalog, runtimeId });
412
+ const revised = applyFlowRevision(request.flowDraft, request.cfDrafts ?? [], normalized.stages, { catalog, runtimeId });
486
413
  store.db.transaction(() => {
487
414
  for (const draft of revised.cfDrafts)
488
415
  store.save('cf_drafts', draft.cfId, draft);
@@ -516,8 +443,7 @@ export function createApp(store = new Store()) {
516
443
  return { deleted: true };
517
444
  });
518
445
  app.post('/api/flows', async (req) => {
519
- const draft = normalizeNewFlowWorkspace(req.body);
520
- operationalWorkspace(draft);
446
+ const draft = scopeFlowDraft(req.body);
521
447
  const catalog = versions();
522
448
  const plan = await pinRuntimeProfiles(compileFlow(draft, new Map(catalog.map((v) => [`${v.cfId}@${v.version}`, v]))), catalog);
523
449
  store.save('flow_drafts', draft.flowId, draft);
@@ -525,14 +451,14 @@ export function createApp(store = new Store()) {
525
451
  return plan;
526
452
  });
527
453
  app.post('/api/flow-tests', async (req) => {
528
- operationalWorkspace(req.body.flowDraft);
454
+ const scopedDraft = scopeFlowDraft(req.body.flowDraft);
529
455
  const candidateVersions = (req.body.cfDrafts ?? []).map(compileCF);
530
456
  const catalog = new Map([...versions(), ...candidateVersions].map((version) => [
531
457
  `${version.cfId}@${version.version}`,
532
458
  version,
533
459
  ]));
534
- const selectedDraft = applyCompileRuntime(req.body.flowDraft, req.body.runtimeId);
535
- store.save('flow_drafts', req.body.flowDraft.flowId, req.body.flowDraft);
460
+ const selectedDraft = applyCompileRuntime(scopedDraft, req.body.runtimeId);
461
+ store.save('flow_drafts', scopedDraft.flowId, scopedDraft);
536
462
  const compiled = compileFlow(selectedDraft, catalog);
537
463
  const plan = await pinRuntimeProfiles(compiled, [...versions(), ...candidateVersions]);
538
464
  const programs = [...versions(), ...candidateVersions].filter((version) => selectedDraft.nodes.some((node) => node.kind === 'cf-call' &&
@@ -557,7 +483,8 @@ export function createApp(store = new Store()) {
557
483
  const plan = store.get('flow_versions', `${req.body.flowId}@${req.body.flowVersion}`);
558
484
  if (!plan)
559
485
  throw new Error('FLOW_VERSION_NOT_FOUND');
560
- validateWorkspaceRoot(plan.workspaceRoot);
486
+ if (plan.workspaceRoot !== workspaceRoot)
487
+ throw new Error('FLOW_WORKSPACE_MISMATCH');
561
488
  const resources = resolveResources(plan, req.body.resourceProfileId);
562
489
  const id = newRunId();
563
490
  store.createRun(id, `${plan.flowId}@${plan.flowVersion}`, req.body.input ?? {}, resources, req.body.resourceProfileId);
@@ -643,10 +570,20 @@ export const isDirectExecution = (entryPath = process.argv[1]) => {
643
570
  }
644
571
  };
645
572
  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
- });
573
+ let app;
574
+ try {
575
+ const workspace = initializeWorkspace();
576
+ app = createApp(new Store(workspace.databasePath), workspace.root);
577
+ app.log.info({ workspace: workspace.root, database: workspace.databasePath }, 'workspace ready');
578
+ await app.listen({
579
+ host: process.env.HOST ?? '127.0.0.1',
580
+ port: Number(process.env.PORT ?? 3000),
581
+ });
582
+ }
583
+ catch (error) {
584
+ if (app)
585
+ await app.close();
586
+ console.error(`CFlow 启动失败:${error instanceof Error ? error.message : String(error)}`);
587
+ process.exitCode = 1;
588
+ }
652
589
  }
@@ -1,5 +1,14 @@
1
- import { accessSync, constants, realpathSync, statSync } from 'node:fs';
2
- import { isAbsolute, relative, resolve, sep } from 'node:path';
1
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from 'node:fs';
2
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ const gitIgnoreStart = '# cflow:local-data';
4
+ const gitIgnoreEnd = '# /cflow:local-data';
5
+ const managedGitIgnore = `${gitIgnoreStart}
6
+ /cflow.sqlite
7
+ /cflow.sqlite-shm
8
+ /cflow.sqlite-wal
9
+ /cflow.sqlite-journal
10
+ /flows/
11
+ ${gitIgnoreEnd}`;
3
12
  export function isWithinDirectory(root, candidate) {
4
13
  const path = relative(resolve(root), resolve(candidate));
5
14
  return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path));
@@ -24,3 +33,35 @@ export function requireWorkspaceRoot(value) {
24
33
  throw new Error('FLOW_WORKSPACE_REQUIRED');
25
34
  return value;
26
35
  }
36
+ function maintainWorkspaceGitIgnore(dataDirectory) {
37
+ const file = join(dataDirectory, '.gitignore');
38
+ const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
39
+ const start = current.indexOf(gitIgnoreStart);
40
+ const end = current.indexOf(gitIgnoreEnd);
41
+ let next;
42
+ if (start >= 0 && end >= start) {
43
+ next = `${current.slice(0, start)}${managedGitIgnore}${current.slice(end + gitIgnoreEnd.length)}`;
44
+ }
45
+ else {
46
+ next = `${current.trimEnd()}${current.trim() ? '\n\n' : ''}${managedGitIgnore}\n`;
47
+ }
48
+ if (next !== current)
49
+ writeFileSync(file, next);
50
+ }
51
+ export function initializeWorkspace(value = process.cwd(), environment = process.env) {
52
+ if (Object.prototype.hasOwnProperty.call(environment, 'CF_DB'))
53
+ throw new Error('CF_DB_UNSUPPORTED');
54
+ const root = validateWorkspaceRoot(resolve(value), 'WORKSPACE_UNAVAILABLE');
55
+ const requestedDataDirectory = join(root, '.cflow');
56
+ mkdirSync(requestedDataDirectory, { recursive: true });
57
+ const dataDirectory = realpathSync(requestedDataDirectory);
58
+ if (!isWithinDirectory(root, dataDirectory))
59
+ throw new Error('WORKSPACE_DATA_OUTSIDE_ROOT');
60
+ accessSync(dataDirectory, constants.R_OK | constants.W_OK);
61
+ maintainWorkspaceGitIgnore(dataDirectory);
62
+ return {
63
+ root,
64
+ dataDirectory,
65
+ databasePath: join(dataDirectory, 'cflow.sqlite'),
66
+ };
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmj-ai/cflow",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "以 Flow 为核心的本机多 Agent 编排工作台",
5
5
  "main": "dist/src/server.js",
6
6
  "bin": {
@@ -51,6 +51,7 @@
51
51
  "@xyflow/react": "^12.11.3",
52
52
  "ajv": "^8.20.0",
53
53
  "better-sqlite3": "^13.0.3",
54
+ "cross-spawn": "^7.0.6",
54
55
  "fastify": "^5.12.1",
55
56
  "lucide-react": "^1.34.0",
56
57
  "react": "^19.2.8",