@hmj-ai/cflow 1.1.0 → 1.3.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,23 @@ 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
- import { compileCF, compileFlow } from './compiler.js';
10
+ import { assertFileReferences, 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';
15
+ import { missingWorkspaceFiles, searchWorkspaceFiles } from './workspace-files.js';
16
16
  import { collectGroundingFailures, groundingError, assertAttachmentGrounding, applyFlowRevision, buildProposalGraph, flowProposalOutputSchema, flowProposalPrompt, matchPublishedCapabilities, prepareSkillAttachments, } from './proposal.js';
17
17
  import { flowAgentContext, flowAgentFallback, flowAgentOutputSchema, flowAgentPrompt, normalizeAgentResponse, } from './flow-agent.js';
18
- export function createApp(store = new Store()) {
18
+ export function createApp(store = new Store(), requestedWorkspaceRoot = process.cwd()) {
19
+ const workspaceRoot = validateWorkspaceRoot(requestedWorkspaceRoot, 'WORKSPACE_UNAVAILABLE');
19
20
  const versions = () => store.list('cf_versions');
20
21
  const flowCompilations = () => store.flowCompilations();
21
- const runtimes = new RuntimeManager(store);
22
+ const runtimes = new RuntimeManager(store, { projectRoot: workspaceRoot });
22
23
  const testPlans = new Map();
23
24
  const testCatalogs = new Map();
24
25
  const executorRegistry = builtins();
@@ -27,42 +28,7 @@ export function createApp(store = new Store()) {
27
28
  ...versions(),
28
29
  ...[...testCatalogs.values()].flat(),
29
30
  ]);
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
- };
31
+ const scopeFlowDraft = (draft) => ({ ...draft, workspaceRoot });
66
32
  const app = Fastify({ logger: true });
67
33
  app.register(fastifyMultipart, {
68
34
  // Do not silently truncate large skill bundles; files are streamed to disk.
@@ -110,7 +76,7 @@ export function createApp(store = new Store()) {
110
76
  nodes: draft.nodes.map((node) => node.kind === 'cf-call' && !node.executor ? { ...node, executor: selected } : node),
111
77
  };
112
78
  };
113
- const saveCompilationSnapshot = (mode, flowDraft, plan, programs, runId) => {
79
+ const saveCompilationSnapshot = (mode, flowDraft, plan, programs, runId, warnings) => {
114
80
  const snapshot = {
115
81
  id: `${flowDraft.flowId}@${flowDraft.revision}:${mode}`,
116
82
  flowId: flowDraft.flowId,
@@ -119,6 +85,7 @@ export function createApp(store = new Store()) {
119
85
  flowDraft,
120
86
  plan,
121
87
  programs,
88
+ ...(warnings?.length ? { warnings } : {}),
122
89
  runId,
123
90
  createdAt: new Date().toISOString(),
124
91
  };
@@ -159,7 +126,7 @@ export function createApp(store = new Store()) {
159
126
  await rm(attachments.root, { recursive: true, force: true });
160
127
  return proposal;
161
128
  }
162
- const draft = normalizeNewFlowWorkspace(proposal.flowDraft);
129
+ const draft = scopeFlowDraft(proposal.flowDraft);
163
130
  let archivePath;
164
131
  if (attachments) {
165
132
  archivePath = join('.cflow', 'flows', draft.flowId, 'attachments');
@@ -180,7 +147,11 @@ export function createApp(store = new Store()) {
180
147
  await rm(attachments.root, { recursive: true, force: true });
181
148
  }
182
149
  }
183
- store.save('flow_drafts', draft.flowId, draft);
150
+ store.db.transaction(() => {
151
+ store.save('flow_drafts', draft.flowId, draft);
152
+ for (const cfDraft of proposal.cfDrafts ?? [])
153
+ store.save('cf_drafts', cfDraft.cfId, cfDraft);
154
+ })();
184
155
  return {
185
156
  ...proposal,
186
157
  flowDraft: draft,
@@ -191,49 +162,10 @@ export function createApp(store = new Store()) {
191
162
  };
192
163
  app.register(fastifyStatic, { root: runtimePublicRoot });
193
164
  app.get('/', async (_, reply) => reply.sendFile('index.html'));
165
+ app.get('/api/workspace', async () => ({ root: workspaceRoot }));
166
+ app.post('/api/workspace/files/search', async (req) => searchWorkspaceFiles(workspaceRoot, req.body ?? {}));
194
167
  app.get('/api/settings', async () => runtimes.settings());
195
168
  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
169
  const runtimeCatalog = () => Promise.all(runtimes.profiles().map(async (profile) => ({
238
170
  ...profile,
239
171
  health: await runtimes.health(profile.id),
@@ -284,13 +216,32 @@ export function createApp(store = new Store()) {
284
216
  app.get('/api/flows', async () => store.list('flow_versions'));
285
217
  app.get('/api/flow-drafts', async () => store.list('flow_drafts'));
286
218
  app.put('/api/flow-drafts/:id', async (req) => {
287
- if (req.params.id !== req.body.flowId)
219
+ const { flowDraft, cfDrafts } = req.body ?? {};
220
+ if (req.params.id !== flowDraft?.flowId)
288
221
  throw new Error('FLOW_DRAFT_ID_MISMATCH');
289
- if (!req.body.flowId?.trim() || !req.body.name?.trim() || !req.body.objective?.trim())
222
+ if (!flowDraft.flowId?.trim() || !flowDraft.name?.trim() || !flowDraft.objective?.trim())
290
223
  throw new Error('FLOW_DRAFT_INVALID');
291
- const draft = normalizeNewFlowWorkspace(req.body);
292
- store.save('flow_drafts', draft.flowId, draft);
293
- return draft;
224
+ if (!Array.isArray(cfDrafts))
225
+ throw new Error('CF_DRAFTS_REQUIRED');
226
+ const ids = new Set();
227
+ for (const cfDraft of cfDrafts) {
228
+ if (!cfDraft.cfId?.trim() || !Number.isInteger(cfDraft.revision) || cfDraft.revision < 1)
229
+ throw new Error('CF_DRAFT_INVALID');
230
+ if (ids.has(cfDraft.cfId))
231
+ throw new Error('CF_DRAFT_DUPLICATE');
232
+ ids.add(cfDraft.cfId);
233
+ assertFileReferences(cfDraft.fileReferences);
234
+ }
235
+ const draft = scopeFlowDraft(flowDraft);
236
+ const stored = store.get('flow_drafts', draft.flowId);
237
+ if (stored && stored.revision > draft.revision)
238
+ throw new Error('FLOW_DRAFT_STALE');
239
+ store.db.transaction(() => {
240
+ store.save('flow_drafts', draft.flowId, draft);
241
+ for (const cfDraft of cfDrafts)
242
+ store.save('cf_drafts', cfDraft.cfId, cfDraft);
243
+ })();
244
+ return { flowDraft: draft, cfDrafts };
294
245
  });
295
246
  app.delete('/api/flow-drafts/:id', async (req, reply) => {
296
247
  const result = store.db.prepare('DELETE FROM flow_drafts WHERE id=?').run(req.params.id);
@@ -308,26 +259,58 @@ export function createApp(store = new Store()) {
308
259
  app.delete('/api/flows/:id', async (req, reply) => deletePublishedFlow(req.params.id, reply));
309
260
  app.delete('/api/flows/:flowId/:flowVersion', async (req, reply) => deletePublishedFlow(`${req.params.flowId}@${req.params.flowVersion}`, reply));
310
261
  app.post('/api/flow-compilations', async (req) => {
311
- assertFlowWorkspaceImmutable(req.body.flowDraft);
262
+ const scopedDraft = scopeFlowDraft(req.body.flowDraft);
312
263
  const candidates = (req.body.cfDrafts ?? []).map(compileCF);
313
264
  const catalog = new Map([...versions(), ...candidates].map((version) => [
314
265
  `${version.cfId}@${version.version}`,
315
266
  version,
316
267
  ]));
317
- const selectedDraft = applyCompileRuntime(req.body.flowDraft, req.body.runtimeId);
268
+ const selectedDraft = applyCompileRuntime(scopedDraft, req.body.runtimeId);
318
269
  const compiled = compileFlow(selectedDraft, catalog);
319
270
  const plan = req.body.runtimeId
320
271
  ? await pinRuntimeProfiles(compiled, [...versions(), ...candidates])
321
272
  : compiled;
273
+ const warnings = [];
274
+ for (const node of selectedDraft.nodes) {
275
+ if (node.kind !== 'cf-call')
276
+ continue;
277
+ const version = catalog.get(`${node.cfRef.cfId}@${node.cfRef.version}`);
278
+ const hasFileAccess = (version?.draft.effects ?? []).some((effect) => effect.type === 'file-read' || effect.type === 'file-write');
279
+ if (!hasFileAccess || !version)
280
+ continue;
281
+ const fileReferences = version.draft.fileReferences ?? [];
282
+ for (const path of await missingWorkspaceFiles(workspaceRoot, fileReferences)) {
283
+ warnings.push({
284
+ code: 'INDEXED_FILE_MISSING',
285
+ cfId: version.cfId,
286
+ nodeId: node.id,
287
+ path,
288
+ message: `引用文件不存在:${path}`,
289
+ });
290
+ }
291
+ const indexed = new Set(fileReferences);
292
+ for (const match of version.draft.does.matchAll(/@\{([^}]+)\}/g)) {
293
+ const path = match[1];
294
+ if (!indexed.has(path))
295
+ warnings.push({
296
+ code: 'FILE_MENTION_NOT_INDEXED',
297
+ cfId: version.cfId,
298
+ nodeId: node.id,
299
+ path,
300
+ message: `任务中的文件没有加入引用列表:${path}`,
301
+ });
302
+ }
303
+ }
322
304
  saveCompilationSnapshot('preview', selectedDraft, plan, [...versions(), ...candidates].filter((version) => selectedDraft.nodes.some((node) => node.kind === 'cf-call' &&
323
305
  node.cfRef.cfId === version.cfId &&
324
- node.cfRef.version === version.version)));
306
+ node.cfRef.version === version.version)), undefined, warnings);
325
307
  const referenced = new Set(selectedDraft.nodes
326
308
  .filter((node) => node.kind === 'cf-call')
327
309
  .map((node) => `${node.cfRef.cfId}@${node.cfRef.version}`));
328
310
  return {
329
311
  plan,
330
312
  programs: [...catalog.values()].filter((version) => referenced.has(`${version.cfId}@${version.version}`)),
313
+ warnings,
331
314
  };
332
315
  });
333
316
  app.post('/api/flow-proposals', async (req) => {
@@ -343,7 +326,6 @@ export function createApp(store = new Store()) {
343
326
  return new Error(code);
344
327
  };
345
328
  const body = multipart?.fields ?? req.body;
346
- const workspaceRoot = validateWorkspaceRoot(body.workspaceRoot, 'FLOW_WORKSPACE_UNAVAILABLE');
347
329
  const objective = String(body.objective ?? '').trim();
348
330
  if (!objective)
349
331
  throw await failing('OBJECTIVE_REQUIRED');
@@ -455,15 +437,18 @@ export function createApp(store = new Store()) {
455
437
  const message = req.body.message?.trim();
456
438
  if (!message)
457
439
  throw new Error('AGENT_MESSAGE_REQUIRED');
440
+ const request = {
441
+ ...req.body,
442
+ flowDraft: req.body.flowDraft ? scopeFlowDraft(req.body.flowDraft) : null,
443
+ };
458
444
  const catalog = versions();
459
- const fallback = flowAgentFallback({ ...req.body, message }, catalog);
460
- const runtimeId = req.body.runtimeId?.trim() || runtimes.settings().defaultRuntimeId;
445
+ const fallback = flowAgentFallback({ ...request, message }, catalog);
446
+ const runtimeId = request.runtimeId?.trim() || runtimes.settings().defaultRuntimeId;
461
447
  const profile = runtimeId ? runtimes.profile(runtimeId) : undefined;
462
448
  if (!profile || profile.backend === 'builtin')
463
449
  return { ...fallback, fallback: true };
464
- if (!req.body.flowDraft)
450
+ if (!request.flowDraft)
465
451
  throw new Error('FLOW_WORKSPACE_REQUIRED');
466
- const workspaceRoot = operationalWorkspace(req.body.flowDraft);
467
452
  const health = await runtimes.health(runtimeId);
468
453
  if (health.status !== 'available')
469
454
  return { ...fallback, fallback: true };
@@ -476,13 +461,13 @@ export function createApp(store = new Store()) {
476
461
  availableRuntimes.push({ id: item.id, name: item.name });
477
462
  }
478
463
  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 });
464
+ const response = await runtimes.execute(runtimeId, flowAgentPrompt(message, grounded), flowAgentContext({ ...request, message }, availableRuntimes, catalog), AbortSignal.timeout(runtimes.settings().testTimeoutMs), [], flowAgentOutputSchema(grounded), { workspaceRoot });
480
465
  const normalized = normalizeAgentResponse(response);
481
466
  if (normalized.intent === 'answer')
482
467
  return { ...normalized, runtimeId };
483
468
  if (grounded)
484
469
  assertAttachmentGrounding(normalized, req.body.attachments ?? []);
485
- const revised = applyFlowRevision(req.body.flowDraft, req.body.cfDrafts ?? [], normalized.stages, { catalog, runtimeId });
470
+ const revised = applyFlowRevision(request.flowDraft, request.cfDrafts ?? [], normalized.stages, { catalog, runtimeId });
486
471
  store.db.transaction(() => {
487
472
  for (const draft of revised.cfDrafts)
488
473
  store.save('cf_drafts', draft.cfId, draft);
@@ -516,8 +501,7 @@ export function createApp(store = new Store()) {
516
501
  return { deleted: true };
517
502
  });
518
503
  app.post('/api/flows', async (req) => {
519
- const draft = normalizeNewFlowWorkspace(req.body);
520
- operationalWorkspace(draft);
504
+ const draft = scopeFlowDraft(req.body);
521
505
  const catalog = versions();
522
506
  const plan = await pinRuntimeProfiles(compileFlow(draft, new Map(catalog.map((v) => [`${v.cfId}@${v.version}`, v]))), catalog);
523
507
  store.save('flow_drafts', draft.flowId, draft);
@@ -525,14 +509,18 @@ export function createApp(store = new Store()) {
525
509
  return plan;
526
510
  });
527
511
  app.post('/api/flow-tests', async (req) => {
528
- operationalWorkspace(req.body.flowDraft);
512
+ const scopedDraft = scopeFlowDraft(req.body.flowDraft);
529
513
  const candidateVersions = (req.body.cfDrafts ?? []).map(compileCF);
530
514
  const catalog = new Map([...versions(), ...candidateVersions].map((version) => [
531
515
  `${version.cfId}@${version.version}`,
532
516
  version,
533
517
  ]));
534
- const selectedDraft = applyCompileRuntime(req.body.flowDraft, req.body.runtimeId);
535
- store.save('flow_drafts', req.body.flowDraft.flowId, req.body.flowDraft);
518
+ const selectedDraft = applyCompileRuntime(scopedDraft, req.body.runtimeId);
519
+ store.db.transaction(() => {
520
+ store.save('flow_drafts', scopedDraft.flowId, scopedDraft);
521
+ for (const cfDraft of req.body.cfDrafts ?? [])
522
+ store.save('cf_drafts', cfDraft.cfId, cfDraft);
523
+ })();
536
524
  const compiled = compileFlow(selectedDraft, catalog);
537
525
  const plan = await pinRuntimeProfiles(compiled, [...versions(), ...candidateVersions]);
538
526
  const programs = [...versions(), ...candidateVersions].filter((version) => selectedDraft.nodes.some((node) => node.kind === 'cf-call' &&
@@ -557,7 +545,8 @@ export function createApp(store = new Store()) {
557
545
  const plan = store.get('flow_versions', `${req.body.flowId}@${req.body.flowVersion}`);
558
546
  if (!plan)
559
547
  throw new Error('FLOW_VERSION_NOT_FOUND');
560
- validateWorkspaceRoot(plan.workspaceRoot);
548
+ if (plan.workspaceRoot !== workspaceRoot)
549
+ throw new Error('FLOW_WORKSPACE_MISMATCH');
561
550
  const resources = resolveResources(plan, req.body.resourceProfileId);
562
551
  const id = newRunId();
563
552
  store.createRun(id, `${plan.flowId}@${plan.flowVersion}`, req.body.input ?? {}, resources, req.body.resourceProfileId);
@@ -643,10 +632,20 @@ export const isDirectExecution = (entryPath = process.argv[1]) => {
643
632
  }
644
633
  };
645
634
  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
- });
635
+ let app;
636
+ try {
637
+ const workspace = initializeWorkspace();
638
+ app = createApp(new Store(workspace.databasePath), workspace.root);
639
+ app.log.info({ workspace: workspace.root, database: workspace.databasePath }, 'workspace ready');
640
+ await app.listen({
641
+ host: process.env.HOST ?? '127.0.0.1',
642
+ port: Number(process.env.PORT ?? 3000),
643
+ });
644
+ }
645
+ catch (error) {
646
+ if (app)
647
+ await app.close();
648
+ console.error(`CFlow 启动失败:${error instanceof Error ? error.message : String(error)}`);
649
+ process.exitCode = 1;
650
+ }
652
651
  }
@@ -0,0 +1,82 @@
1
+ import { lstat, readdir, realpath } from 'node:fs/promises';
2
+ import { relative, resolve, sep } from 'node:path';
3
+ const EXCLUDED_DIRECTORIES = new Set([
4
+ '.git',
5
+ '.cflow',
6
+ 'node_modules',
7
+ 'dist',
8
+ 'build',
9
+ 'coverage',
10
+ ]);
11
+ const RESULT_LIMIT = 200;
12
+ function normalizedRelativePath(value) {
13
+ const path = value.trim().replaceAll('\\', '/');
14
+ if (!path || path.startsWith('/') || /^[A-Za-z]:/.test(path))
15
+ return null;
16
+ const parts = path.split('/');
17
+ if (parts.some((part) => !part || part === '.' || part === '..'))
18
+ return null;
19
+ if (parts.some((part) => EXCLUDED_DIRECTORIES.has(part)))
20
+ return null;
21
+ return path;
22
+ }
23
+ async function isWorkspaceFile(root, path) {
24
+ const normalized = normalizedRelativePath(path);
25
+ if (!normalized)
26
+ return false;
27
+ const target = resolve(root, ...normalized.split('/'));
28
+ try {
29
+ const [stat, resolvedTarget] = await Promise.all([lstat(target), realpath(target)]);
30
+ const scope = relative(root, resolvedTarget);
31
+ return (stat.isFile() && scope !== '..' && !scope.startsWith(`..${sep}`) && !scope.startsWith(sep));
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ async function indexedFiles(root) {
38
+ const files = [];
39
+ const walk = async (directory, prefix) => {
40
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
41
+ for (const entry of entries) {
42
+ if (entry.isSymbolicLink())
43
+ continue;
44
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
45
+ if (entry.isDirectory()) {
46
+ if (!EXCLUDED_DIRECTORIES.has(entry.name))
47
+ await walk(resolve(directory, entry.name), path);
48
+ }
49
+ else if (entry.isFile()) {
50
+ files.push(path);
51
+ }
52
+ }
53
+ };
54
+ await walk(root, '');
55
+ return files.sort((left, right) => left.localeCompare(right));
56
+ }
57
+ export async function searchWorkspaceFiles(root, input) {
58
+ const query = String(input.query ?? '')
59
+ .trim()
60
+ .toLocaleLowerCase();
61
+ const safeSelected = [
62
+ ...new Set((input.selected ?? []).map(normalizedRelativePath).filter(Boolean)),
63
+ ];
64
+ const files = await indexedFiles(root);
65
+ const filtered = query ? files.filter((path) => path.toLocaleLowerCase().includes(query)) : files;
66
+ const missing = [];
67
+ for (const path of safeSelected)
68
+ if (!(await isWorkspaceFile(root, path)))
69
+ missing.push(path);
70
+ return {
71
+ matches: filtered.slice(0, RESULT_LIMIT),
72
+ missing,
73
+ truncated: filtered.length > RESULT_LIMIT,
74
+ };
75
+ }
76
+ export async function missingWorkspaceFiles(root, paths) {
77
+ const missing = [];
78
+ for (const path of paths)
79
+ if (!(await isWorkspaceFile(root, path)))
80
+ missing.push(path);
81
+ return missing;
82
+ }
@@ -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.3.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",