@tom2012/cc-web 2026.5.12-c → 2026.5.14-a

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.
Files changed (34) hide show
  1. package/backend/dist/flows/runner.d.ts +4 -11
  2. package/backend/dist/flows/runner.d.ts.map +1 -1
  3. package/backend/dist/flows/runner.js +216 -272
  4. package/backend/dist/flows/runner.js.map +1 -1
  5. package/backend/dist/flows/store.d.ts +22 -18
  6. package/backend/dist/flows/store.d.ts.map +1 -1
  7. package/backend/dist/flows/store.js +68 -60
  8. package/backend/dist/flows/store.js.map +1 -1
  9. package/backend/dist/flows/types.d.ts +103 -58
  10. package/backend/dist/flows/types.d.ts.map +1 -1
  11. package/backend/dist/flows/types.js +20 -8
  12. package/backend/dist/flows/types.js.map +1 -1
  13. package/backend/dist/routes/flows.d.ts.map +1 -1
  14. package/backend/dist/routes/flows.js +197 -96
  15. package/backend/dist/routes/flows.js.map +1 -1
  16. package/frontend/dist/assets/{ChatOverlay-CGs4pH85.js → ChatOverlay-DsfZXjjz.js} +1 -1
  17. package/frontend/dist/assets/{GraphPreview-C9IEh8-V.js → GraphPreview-BGZHA8KW.js} +1 -1
  18. package/frontend/dist/assets/{MobilePage-BIiXwC0r.js → MobilePage-B-FiVfUE.js} +3 -3
  19. package/frontend/dist/assets/{OfficePreview-DFO94qTw.js → OfficePreview-CvHPeAlI.js} +2 -2
  20. package/frontend/dist/assets/{PdfPreview-QbDUi7dV.js → PdfPreview-D1OVG7jG.js} +1 -1
  21. package/frontend/dist/assets/{ProjectPage-B6L7cAC5.js → ProjectPage-CyKEenc5.js} +5 -5
  22. package/frontend/dist/assets/{SettingsPage-w8_jSK67.js → SettingsPage-CIe3HZml.js} +1 -1
  23. package/frontend/dist/assets/{SkillHubPage-hGWa2TIm.js → SkillHubPage-CG1-G4Pf.js} +1 -1
  24. package/frontend/dist/assets/{chevron-down-IcohQuLb.js → chevron-down-CS7uu2Ol.js} +1 -1
  25. package/frontend/dist/assets/{index-BxEZe7dG.js → index-BThL9NV3.js} +2 -2
  26. package/frontend/dist/assets/index-BkQ6KI1l.css +1 -0
  27. package/frontend/dist/assets/{index-Cx9N3v4o.js → index-DCc5jmus.js} +1 -1
  28. package/frontend/dist/assets/{index-CqiN8JNB.js → index-DN91i4kg.js} +1 -1
  29. package/frontend/dist/assets/{jszip.min-dTvFz_Ar.js → jszip.min-DHcltCpp.js} +1 -1
  30. package/frontend/dist/assets/{select-CQRCt-uM.js → select-C09dwvOR.js} +1 -1
  31. package/frontend/dist/assets/{user-CfulB_DL.js → user-Bj8X-WCQ.js} +1 -1
  32. package/frontend/dist/index.html +2 -2
  33. package/package.json +1 -1
  34. package/frontend/dist/assets/index-TYqvVaSs.css +0 -1
@@ -41,51 +41,114 @@ const uuid_1 = require("uuid");
41
41
  const logger_1 = require("../logger");
42
42
  const store_1 = require("./store");
43
43
  const log = (0, logger_1.modLogger)('flow-runner');
44
- /** Build a bracketed-paste payload that the LLM CLI submits as one chat
45
- * message. The CR at the end triggers Enter; embedded paste markers in
46
- * the body are stripped to prevent mode escape. */
44
+ /** Wrap text in a bracketed-paste sequence + trailing CR.
45
+ * Strips all ESC sequences and CRs from the body escape sequences embedded
46
+ * in either promptTemplate (codex P2-G) or variable values would otherwise
47
+ * corrupt Ink TUI state or close paste mode prematurely. LF and TAB are
48
+ * preserved so the body's structure (paragraphs, code indentation) is
49
+ * intact. The final CR is re-added after the close-marker to submit. */
47
50
  function buildPaste(text) {
48
- const safe = text.replace(/\x1b\[20[01]~/g, '');
51
+ const safe = text.replace(/[\x1b\r]/g, '');
49
52
  return `\x1b[200~${safe}\x1b[201~\r`;
50
53
  }
51
- /** Substitute `{{file:relpath}}` tokens with the file's UTF-8 content.
52
- * Missing files render as `[ERROR reading <path>: <reason>]` the runner
53
- * separately surfaces read failures via provider-aware error routing
54
- * before we get here, so this substitution path is only a defense for
55
- * unexpected misses. */
56
- function renderTemplate(folderPath, tpl) {
57
- return tpl.replace(/\{\{file:([^}]+)\}\}/g, (_m, rel) => {
58
- const abs = (0, store_1.safeProjectPath)(folderPath, rel.trim());
59
- if (!abs)
60
- return `[ERROR unsafe path rejected: ${rel}]`;
61
- try {
62
- return fs.readFileSync(abs, 'utf-8');
63
- }
64
- catch (err) {
65
- return `[ERROR reading ${rel}: ${err instanceof Error ? err.message : 'unknown'}]`;
66
- }
54
+ // ── Value rendering & sanitization ────────────────────────────────────────
55
+ /** Strip terminal control bytes that would corrupt bracketed-paste mode or
56
+ * Ink TUI state when the value is later injected into a prompt. ESC sequences
57
+ * (incl. paste-mode markers) can confuse the agent's input parser; bare CR
58
+ * prematurely closes paste mode. We keep LF and TAB so multi-line content
59
+ * renders normally. */
60
+ function sanitizeForPrompt(s) {
61
+ return s.replace(/[\x1b\r]/g, '');
62
+ }
63
+ /** Format an arbitrary JSON value for prompt injection. Strings pass through
64
+ * (after sanitize); other values get JSON.stringify with 2-space indent so
65
+ * arrays/objects are human-readable to the LLM. `undefined` becomes the
66
+ * literal "(未设置)" marker so the LLM knows the variable hasn't been
67
+ * written yet rather than seeing a missing field silently. */
68
+ function formatValueForPrompt(value) {
69
+ if (value === undefined || value === null)
70
+ return '(未设置)';
71
+ if (typeof value === 'string')
72
+ return sanitizeForPrompt(value);
73
+ return sanitizeForPrompt(JSON.stringify(value, null, 2));
74
+ }
75
+ /** Substitute `{{var:name}}` and `{{const:name}}` tokens in the prompt
76
+ * template with the current value from workflow_data. Unset declared
77
+ * variables (those without initialValue and not yet written) render as
78
+ * "(未设置)" — the validator has already gated on the name being declared
79
+ * at save time, so an unknown name at runtime is impossible by construction.
80
+ * (codex P1-C: previously this returned "[ERROR ...]" which leaked into
81
+ * prompts whenever a declared-but-uninitialized variable was referenced.) */
82
+ function renderTemplate(tpl, data) {
83
+ return tpl
84
+ .replace(/\{\{var:([^}]+)\}\}/g, (_m, name) => {
85
+ return formatValueForPrompt(data.variables[name.trim()]);
86
+ })
87
+ .replace(/\{\{const:([^}]+)\}\}/g, (_m, name) => {
88
+ return formatValueForPrompt(data.constants[name.trim()]);
67
89
  });
68
90
  }
69
- /** Build the prompt suffix that instructs the LLM to derive + persist each
70
- * variable named in `initVariables`. Skips unknown names defensively (route
71
- * validator already rejects them at save time). */
72
- function buildInitVarBlock(initNames, variables) {
73
- if (initNames.length === 0)
91
+ // ── Prompt block builders ─────────────────────────────────────────────────
92
+ /** Build a "current variable values" context block for the prompt head. */
93
+ function buildReadVarBlock(names, def, data) {
94
+ if (names.length === 0)
74
95
  return '';
75
- const byName = new Map(variables.map((v) => [v.name, v]));
76
- const lines = [];
77
- lines.push('\n\n──────── 变量初始化指令 ────────');
78
- lines.push('完成本任务后,请按下面列出的含义判断每个变量的值,');
79
- lines.push('用 Write 或 Edit 工具把变量值写入到对应 JSON 文件的顶层字段(字段名 = 变量名)。');
80
- lines.push('如果目标文件不存在请新建;如果已存在请保留其他字段。\n');
81
- for (const name of initNames) {
96
+ const byName = new Map((def.variables ?? []).map((v) => [v.name, v]));
97
+ const lines = ['──────── 流变量当前值 ────────'];
98
+ for (const name of names) {
82
99
  const v = byName.get(name);
83
100
  if (!v)
84
101
  continue;
85
- lines.push(`- \`${v.name}\` 写入文件 \`${v.file}\` 的顶层 \`${v.name}\` 字段。含义:${v.description || '(无描述)'}`);
102
+ const meaning = v.description || '(无描述)';
103
+ lines.push(`变量 \`${v.name}\`(含义:${meaning}):`);
104
+ lines.push(formatValueForPrompt(data.variables[name]));
105
+ lines.push('');
106
+ }
107
+ lines.push('');
108
+ return lines.join('\n');
109
+ }
110
+ /** Build a "constants" context block. Constants are stable per run, so we
111
+ * just dump value + description. */
112
+ function buildReadConstBlock(names, def, data) {
113
+ if (names.length === 0)
114
+ return '';
115
+ const byName = new Map((def.constants ?? []).map((c) => [c.name, c]));
116
+ const lines = ['──────── 流常量 ────────'];
117
+ for (const name of names) {
118
+ const c = byName.get(name);
119
+ if (!c)
120
+ continue;
121
+ const meaning = c.description || '(无描述)';
122
+ lines.push(`常量 \`${c.name}\`(含义:${meaning}):`);
123
+ lines.push(formatValueForPrompt(data.constants[name]));
124
+ lines.push('');
86
125
  }
126
+ lines.push('');
87
127
  return lines.join('\n');
88
128
  }
129
+ /** Build the prompt suffix instructing the LLM to write each `writeVariables`
130
+ * entry into workflow_data.variables[name]. */
131
+ function buildWriteVarBlock(names, def) {
132
+ if (names.length === 0)
133
+ return '';
134
+ const byName = new Map((def.variables ?? []).map((v) => [v.name, v]));
135
+ const lines = [
136
+ '',
137
+ '──────── 变量写入指令 ────────',
138
+ '完成本任务后,请按下面列出的含义判断每个变量的值,',
139
+ '用 Edit 或 Write 工具把它们写入 .ccweb/workflow_data.json 的 variables 字段(顶层 key = 变量名)。',
140
+ '保留 workflow_data.json 中其他变量、常量、task_progress 字段不要动。',
141
+ '',
142
+ ];
143
+ for (const name of names) {
144
+ const v = byName.get(name);
145
+ if (!v)
146
+ continue;
147
+ lines.push(`- \`${v.name}\` → 写入 variables.${v.name}。含义:${v.description || '(无描述)'}`);
148
+ }
149
+ return lines.join('\n');
150
+ }
151
+ // ── Branch evaluation ─────────────────────────────────────────────────────
89
152
  /** Loose equality for branch evaluation. JSON outputs from LLMs frequently
90
153
  * type-shift (`true` → `"true"`, `1` → `"1"`); branch authors typically
91
154
  * configure the typed primitive, so we coerce common cases. */
@@ -110,34 +173,6 @@ function branchMatches(value, expected) {
110
173
  }
111
174
  return false;
112
175
  }
113
- function readInputs(folderPath, inputs) {
114
- for (const inp of inputs) {
115
- const abs = (0, store_1.safeProjectPath)(folderPath, inp.path);
116
- if (!abs) {
117
- return {
118
- ok: false,
119
- failingProvider: inp.provider,
120
- error: `unsafe path rejected: ${inp.path}`,
121
- };
122
- }
123
- try {
124
- const raw = fs.readFileSync(abs, 'utf-8');
125
- // Best-effort JSON parse — non-JSON inputs (e.g. bibtex) pass through
126
- // here as long as the file exists; a stricter parse, if needed, lives
127
- // in the consuming node (e.g. system-logic parses JSON itself).
128
- if (inp.path.endsWith('.json'))
129
- JSON.parse(raw);
130
- }
131
- catch (err) {
132
- return {
133
- ok: false,
134
- failingProvider: inp.provider,
135
- error: err instanceof Error ? err.message : String(err),
136
- };
137
- }
138
- }
139
- return { ok: true };
140
- }
141
176
  class FlowRunner extends events_1.EventEmitter {
142
177
  constructor() {
143
178
  super(...arguments);
@@ -147,7 +182,6 @@ class FlowRunner extends events_1.EventEmitter {
147
182
  setPromptInjector(fn) {
148
183
  this.injector = fn;
149
184
  }
150
- /** Returns null if a flow is already running for this project. */
151
185
  start(projectId, folderPath, flowDef, flowFilename) {
152
186
  if (this.active.has(projectId)) {
153
187
  return { ok: false, reason: 'already-running' };
@@ -155,7 +189,9 @@ class FlowRunner extends events_1.EventEmitter {
155
189
  const startNode = flowDef.nodes.find((n) => n.id === flowDef.entryNodeId);
156
190
  if (!startNode)
157
191
  return { ok: false, reason: 'entry-node-not-found' };
158
- (0, store_1.resetTaskTodo)(folderPath);
192
+ // Initialize workflow_data: constants written once, variables get
193
+ // initialValue (where declared), task_progress reset for this run.
194
+ (0, store_1.initWorkflowData)(folderPath, flowDef);
159
195
  const state = {
160
196
  flowId: flowDef.id,
161
197
  flowFilename,
@@ -180,7 +216,6 @@ class FlowRunner extends events_1.EventEmitter {
180
216
  userInputResolve: null,
181
217
  userInputReject: null,
182
218
  currentTaskIndex: null,
183
- pendingLlmError: null,
184
219
  };
185
220
  this.active.set(projectId, run);
186
221
  this.emit('state', { projectId, state });
@@ -188,27 +223,19 @@ class FlowRunner extends events_1.EventEmitter {
188
223
  projectId,
189
224
  flowId: flowDef.id,
190
225
  flowName: flowDef.name,
226
+ schemaVersion: flowDef.schemaVersion,
191
227
  entryNodeId: flowDef.entryNodeId,
192
228
  nodeCount: flowDef.nodes.length,
229
+ constantCount: (flowDef.constants ?? []).length,
230
+ variableCount: (flowDef.variables ?? []).length,
193
231
  runId: state.runId,
194
232
  }, 'flow start');
195
- // Fire-and-forget; the loop persists state on each transition.
196
233
  void this.runLoop(run).catch((err) => {
197
234
  log.error({ projectId, err: err instanceof Error ? err.message : String(err) }, 'run loop crashed');
198
235
  this.finalize(run, 'failed', err instanceof Error ? err.message : String(err));
199
236
  });
200
237
  return { ok: true, state };
201
238
  }
202
- /**
203
- * Resume a paused run by re-executing the current node. Caller intent:
204
- * - timeout → re-inject prompt, wait again
205
- * - max-retries-exceeded → reset that node's loop counter so user gets
206
- * a fresh allotment (otherwise resume would immediately hit the same
207
- * cap and pause again)
208
- * - file-read-error → user fixed the file; re-read succeeds and node
209
- * proceeds normally
210
- * - awaiting-user-input → wrong path; caller should use submitUserInput
211
- */
212
239
  resume(projectId) {
213
240
  const run = this.active.get(projectId);
214
241
  if (!run)
@@ -247,9 +274,6 @@ class FlowRunner extends events_1.EventEmitter {
247
274
  const hadTaskWait = !!wait;
248
275
  run.userInputResolve = null;
249
276
  run.userInputReject = null;
250
- // settle() inside waitForTaskFinish calls clearWaiters internally, so
251
- // we don't need to call it here for the wait path. For the user-input
252
- // path we still need finalize to clean up (no watcher/timer there).
253
277
  wait?.('aborted');
254
278
  userReject?.('aborted');
255
279
  log.info({ projectId, currentNodeId: run.state.currentNodeId, hadTaskWait, hadUserInputWait }, 'flow abort');
@@ -264,8 +288,6 @@ class FlowRunner extends events_1.EventEmitter {
264
288
  const resolve = run.userInputResolve;
265
289
  run.userInputResolve = null;
266
290
  run.userInputReject = null;
267
- // Log keys + value lengths only — field values may contain user research
268
- // goals / unpublished hypotheses that we don't want in plaintext logs.
269
291
  log.info({
270
292
  projectId,
271
293
  currentNodeId: run.state.currentNodeId,
@@ -307,7 +329,6 @@ class FlowRunner extends events_1.EventEmitter {
307
329
  : outcome.kind === 'retry' ? 'retry'
308
330
  : 'error';
309
331
  if (outcome.kind === 'pause' || outcome.kind === 'error') {
310
- // executeNode already set status/pauseReason; persist + bail out.
311
332
  this.persist(run);
312
333
  return;
313
334
  }
@@ -319,7 +340,6 @@ class FlowRunner extends events_1.EventEmitter {
319
340
  return;
320
341
  }
321
342
  }
322
- // 'retry' just persists; loop continues with same state.currentNodeId
323
343
  }
324
344
  }
325
345
  // ── Node executors ────────────────────────────────────────────────────
@@ -335,52 +355,65 @@ class FlowRunner extends events_1.EventEmitter {
335
355
  return { kind: 'error', message: `unknown node kind: ${node.kind}` };
336
356
  }
337
357
  async executeUserInput(run, node) {
358
+ // Snapshot context values (bindVariable / bindConstant) so the frontend
359
+ // can render them read-only without a separate fetch.
360
+ const data = (0, store_1.readWorkflowData)(run.folderPath);
361
+ const variablesCtx = {};
362
+ const constantsCtx = {};
363
+ for (const field of node.userInputSchema.fields) {
364
+ if (field.bindVariable && field.bindVariable in data.variables) {
365
+ variablesCtx[field.bindVariable] = data.variables[field.bindVariable];
366
+ }
367
+ if (field.bindConstant && field.bindConstant in data.constants) {
368
+ constantsCtx[field.bindConstant] = data.constants[field.bindConstant];
369
+ }
370
+ }
371
+ const contextValues = Object.keys(variablesCtx).length > 0 || Object.keys(constantsCtx).length > 0
372
+ ? {
373
+ variables: Object.keys(variablesCtx).length > 0 ? variablesCtx : undefined,
374
+ constants: Object.keys(constantsCtx).length > 0 ? constantsCtx : undefined,
375
+ }
376
+ : undefined;
338
377
  run.state.status = 'paused';
339
378
  run.state.pauseReason = 'awaiting-user-input';
340
- run.state.pendingUserInput = { nodeId: node.id, fields: node.userInputSchema.fields };
379
+ run.state.pendingUserInput = {
380
+ nodeId: node.id,
381
+ fields: node.userInputSchema.fields,
382
+ contextValues,
383
+ };
341
384
  this.persist(run);
342
385
  this.emit('user-input', { projectId: run.projectId, nodeId: node.id, fields: node.userInputSchema.fields });
343
386
  log.info({
344
387
  projectId: run.projectId,
345
388
  nodeId: node.id,
346
389
  fieldKeys: node.userInputSchema.fields.map((f) => f.key),
347
- outputCount: node.outputs.length,
348
390
  }, 'flow node user-input awaiting');
349
- let data;
391
+ let submitted;
350
392
  try {
351
- data = await new Promise((resolve, reject) => {
393
+ submitted = await new Promise((resolve, reject) => {
352
394
  run.userInputResolve = resolve;
353
395
  run.userInputReject = reject;
354
396
  });
355
397
  }
356
- catch (err) {
357
- // Reject via abort() — outer handler does finalize.
398
+ catch {
358
399
  return { kind: 'pause' };
359
400
  }
360
- // Write outputs: synthesize a JSON object from user fields and write to
361
- // each declared output file. For Phase 1, multi-output user-input nodes
362
- // get the same payload written to each keeps the schema simple.
363
- const payload = {};
364
- for (const field of node.userInputSchema.fields)
365
- payload[field.key] = data[field.key] ?? '';
366
- for (const out of node.outputs) {
367
- const abs = (0, store_1.safeProjectPath)(run.folderPath, out.path);
368
- if (!abs) {
369
- run.state.status = 'failed';
370
- run.state.pauseReason = null;
371
- run.state.pauseDetail = `unsafe output path rejected: ${out.path}`;
372
- return { kind: 'error', message: run.state.pauseDetail };
373
- }
374
- try {
375
- fs.mkdirSync(path.dirname(abs), { recursive: true });
376
- fs.writeFileSync(abs, JSON.stringify(payload, null, 2));
377
- }
378
- catch (err) {
379
- run.state.status = 'failed';
380
- run.state.pauseReason = null;
381
- run.state.pauseDetail = `failed to write ${out.path}: ${err instanceof Error ? err.message : 'unknown'}`;
382
- return { kind: 'error', message: run.state.pauseDetail };
401
+ // Merge field values into variables. For bindVariable / bindConstant
402
+ // fields we re-read from workflow_data (defense client could lie even
403
+ // though the UI disables those inputs); for outputVariable fields we
404
+ // take the submitted value as-is.
405
+ const fresh = (0, store_1.readWorkflowData)(run.folderPath);
406
+ const variableUpdates = {};
407
+ for (const field of node.userInputSchema.fields) {
408
+ if (field.outputVariable) {
409
+ variableUpdates[field.outputVariable] = submitted[field.key] ?? '';
383
410
  }
411
+ // bindVariable / bindConstant fields contribute nothing to writes —
412
+ // they're read-only displays.
413
+ }
414
+ if (Object.keys(variableUpdates).length > 0) {
415
+ fresh.variables = { ...fresh.variables, ...variableUpdates };
416
+ (0, store_1.writeWorkflowData)(run.folderPath, fresh);
384
417
  }
385
418
  run.state.status = 'running';
386
419
  run.state.pauseReason = null;
@@ -389,7 +422,7 @@ class FlowRunner extends events_1.EventEmitter {
389
422
  log.info({
390
423
  projectId: run.projectId,
391
424
  nodeId: node.id,
392
- outputsWritten: node.outputs.map((o) => o.path),
425
+ wroteVariables: Object.keys(variableUpdates),
393
426
  next: node.next,
394
427
  }, 'flow node user-input completed');
395
428
  return { kind: 'ok', next: node.next };
@@ -398,67 +431,37 @@ class FlowRunner extends events_1.EventEmitter {
398
431
  if (!this.injector) {
399
432
  return { kind: 'error', message: 'no prompt injector configured' };
400
433
  }
401
- // 1. Read & validate inputs (provider-aware error routing)
402
- const readResult = readInputs(run.folderPath, node.inputs);
403
- if (!readResult.ok) {
404
- log.warn({
405
- projectId: run.projectId,
406
- nodeId: node.id,
407
- failingProvider: readResult.failingProvider,
408
- error: readResult.error,
409
- inputs: node.inputs.map((i) => i.path),
410
- }, 'flow node llm input read failed');
411
- if (readResult.failingProvider === 'user') {
412
- run.state.status = 'paused';
413
- run.state.pauseReason = 'user-file-read-error';
414
- run.state.pauseDetail = `failed to read input file (provider=user): ${readResult.error}`;
415
- this.emit('error', {
416
- projectId: run.projectId,
417
- nodeId: node.id,
418
- reason: 'user-file-read-error',
419
- detail: run.state.pauseDetail,
420
- });
421
- return { kind: 'pause' };
422
- }
423
- // provider=llm or system — stash error to inject in the next prompt to
424
- // the LLM. Since the input was supposedly produced by an upstream LLM
425
- // node, we still send the prompt to *this* node's LLM but with an
426
- // explanatory wrapper, asking it to handle/repair.
427
- run.pendingLlmError = {
428
- path: node.inputs.find((i) => i.provider !== 'user')?.path ?? '?',
429
- error: readResult.error ?? 'unknown',
430
- };
431
- }
432
- // 2. task_todo entry
433
- const taskIndex = (0, store_1.appendTaskTodo)(run.folderPath, {
434
- id: node.id,
434
+ // 1. Append a task_progress entry for the LLM to flip when done.
435
+ const taskIndex = (0, store_1.appendTaskProgress)(run.folderPath, {
436
+ nodeId: node.id,
435
437
  name: node.name,
436
438
  finish: false,
437
439
  });
438
440
  run.currentTaskIndex = taskIndex;
439
- // 3. Build prompt
440
- const errorBlock = run.pendingLlmError
441
- ? `\n\n[文件读取错误] 上游产物 ${run.pendingLlmError.path} 解析失败:${run.pendingLlmError.error}\n请先修复该文件再继续本任务。\n`
442
- : '';
443
- run.pendingLlmError = null;
441
+ // 2. Build prompt: header + read-context blocks + body + write block.
442
+ const data = (0, store_1.readWorkflowData)(run.folderPath);
444
443
  const taskHeader = `当前任务 id=${node.id},名为「${node.name}」。\n` +
445
- `完成后请把 .ccweb/task_todo.json 中索引 ${taskIndex} 处 entry 的 finish 字段改为 true(用 Edit/Write 工具直接更新该 JSON 文件)。\n` +
446
- `\n──────── 任务正文 ────────\n`;
447
- const body = renderTemplate(run.folderPath, node.promptTemplate);
448
- const initVarBlock = buildInitVarBlock(node.initVariables ?? [], run.flowDef.variables ?? []);
449
- const fullPrompt = `${taskHeader}${body}${initVarBlock}${errorBlock}`;
450
- // 4. Inject into chat
444
+ `完成后请把 .ccweb/workflow_data.json task_progress[${taskIndex}].finish 改为 true(用 Edit/Write 工具直接更新该 JSON 文件)。\n` +
445
+ '保留 workflow_data.json 中其他字段不要动。\n' +
446
+ '\n──────── 任务正文 ────────\n';
447
+ const refConstBlock = buildReadConstBlock(node.readConstants ?? [], run.flowDef, data);
448
+ const refVarBlock = buildReadVarBlock(node.readVariables ?? [], run.flowDef, data);
449
+ const body = renderTemplate(node.promptTemplate, data);
450
+ const writeVarBlock = buildWriteVarBlock(node.writeVariables ?? [], run.flowDef);
451
+ const fullPrompt = `${taskHeader}${refConstBlock}${refVarBlock}${body}${writeVarBlock}`;
452
+ // 3. Inject into chat.
451
453
  this.injector(run.projectId, buildPaste(fullPrompt));
452
454
  log.info({
453
455
  projectId: run.projectId,
454
456
  nodeId: node.id,
455
457
  taskIndex,
456
458
  promptSize: fullPrompt.length,
457
- hasErrorBlock: errorBlock.length > 0,
459
+ readVariables: node.readVariables ?? [],
460
+ readConstants: node.readConstants ?? [],
461
+ writeVariables: node.writeVariables ?? [],
458
462
  timeoutSec: node.timeoutSec,
459
- inputs: node.inputs.map((i) => i.path),
460
463
  }, 'flow node llm prompt injected');
461
- // 5. Wait for task_todo finish:true OR timeout
464
+ // 4. Wait for task_progress[taskIndex].finish OR timeout.
462
465
  const outcome = await this.waitForTaskFinish(run, taskIndex, node.timeoutSec * 1000);
463
466
  log.info({ projectId: run.projectId, nodeId: node.id, taskIndex, outcome }, 'flow node llm wait outcome');
464
467
  if (outcome === 'aborted' || outcome === 'paused') {
@@ -477,87 +480,33 @@ class FlowRunner extends events_1.EventEmitter {
477
480
  log.warn({ projectId: run.projectId, nodeId: node.id, timeoutSec: node.timeoutSec, taskIndex }, 'flow node llm timeout');
478
481
  return { kind: 'pause' };
479
482
  }
480
- // finished clear stale per-task fields
483
+ // Intentionally do NOT write finishedAt back to workflow_data here:
484
+ // the LLM may keep editing variables briefly after flipping finish=true,
485
+ // and our whole-file RMW would silently drop those edits (codex P1-B).
486
+ // Duration audit lives in FlowState.history (saved by runLoop), which
487
+ // is runner-owned so there's no concurrent writer.
481
488
  run.currentTaskIndex = null;
482
489
  return { kind: 'ok', next: node.next };
483
490
  }
484
491
  async executeSystemLogic(run, node) {
485
- // Mixed-mode branch evaluation: each branch is either variable-mode
486
- // (resolve via flow.variables → file+field) or field-mode (legacy:
487
- // node.inputs[0] + branch.field). Files are cached per evaluation pass
488
- // so multi-branch flows touching the same file pay one read each.
489
- const variables = run.flowDef.variables ?? [];
490
- const varByName = new Map(variables.map((v) => [v.name, v]));
491
- const fileCache = new Map();
492
- /** Helper: read+parse a file (provider-aware on read error → pause).
493
- * Returns null when an error has been recorded and caller should
494
- * return pause; otherwise returns the parsed object. */
495
- const readFileForBranch = (relPath, provider) => {
496
- const cached = fileCache.get(relPath);
497
- if (cached)
498
- return cached;
499
- const abs = (0, store_1.safeProjectPath)(run.folderPath, relPath);
500
- if (!abs) {
501
- run.state.status = 'paused';
502
- run.state.pauseReason = 'user-file-read-error';
503
- run.state.pauseDetail = `unsafe input path rejected: ${relPath}`;
504
- return 'pause';
505
- }
506
- try {
507
- const parsed = JSON.parse(fs.readFileSync(abs, 'utf-8'));
508
- const obj = (parsed && typeof parsed === 'object') ? parsed : {};
509
- fileCache.set(relPath, obj);
510
- return obj;
511
- }
512
- catch (err) {
513
- const msg = err instanceof Error ? err.message : String(err);
514
- log.warn({ projectId: run.projectId, nodeId: node.id, path: relPath, provider, error: msg }, 'flow node system-logic input parse failed');
515
- run.state.status = 'paused';
516
- run.state.pauseReason = provider === 'user' ? 'user-file-read-error' : 'llm-file-read-error';
517
- run.state.pauseDetail = `failed to parse ${relPath} (provider=${provider}): ${msg}`;
518
- if (provider !== 'user')
519
- run.pendingLlmError = { path: relPath, error: msg };
520
- this.emit('error', {
521
- projectId: run.projectId,
522
- nodeId: node.id,
523
- reason: run.state.pauseReason,
524
- detail: run.state.pauseDetail,
525
- });
526
- return 'pause';
527
- }
528
- };
529
- // Evaluate branches with loose comparison — LLM often writes JSON
530
- // booleans as strings ("true"/"false") or numbers; branch authors
531
- // probably configured the typed primitive.
492
+ const data = (0, store_1.readWorkflowData)(run.folderPath);
532
493
  let matched = null;
533
- let matchedActual = undefined;
534
- let matchedSourceFile;
535
- let matchedSourceField;
494
+ let matchedActual;
495
+ let matchedSource;
496
+ let matchedName;
536
497
  for (const rule of node.branches) {
537
498
  let actual;
538
- let sourceFile;
539
- let sourceField;
499
+ let source;
500
+ let name;
540
501
  if (rule.variable) {
541
- const v = varByName.get(rule.variable);
542
- if (!v)
543
- continue; // already rejected at validation; defensive skip
544
- const obj = readFileForBranch(v.file, 'llm');
545
- if (obj === 'pause')
546
- return { kind: 'pause' };
547
- actual = obj[v.name];
548
- sourceFile = v.file;
549
- sourceField = v.name;
502
+ actual = data.variables[rule.variable];
503
+ source = 'variable';
504
+ name = rule.variable;
550
505
  }
551
- else if (rule.field) {
552
- const legacyInp = node.inputs[0];
553
- if (!legacyInp)
554
- return { kind: 'error', message: `node ${node.id} field-mode branch needs inputs[0]` };
555
- const obj = readFileForBranch(legacyInp.path, legacyInp.provider);
556
- if (obj === 'pause')
557
- return { kind: 'pause' };
558
- actual = obj[rule.field];
559
- sourceFile = legacyInp.path;
560
- sourceField = rule.field;
506
+ else if (rule.constant) {
507
+ actual = data.constants[rule.constant];
508
+ source = 'constant';
509
+ name = rule.constant;
561
510
  }
562
511
  else {
563
512
  continue;
@@ -565,8 +514,8 @@ class FlowRunner extends events_1.EventEmitter {
565
514
  if (branchMatches(actual, rule.equals)) {
566
515
  matched = rule;
567
516
  matchedActual = actual;
568
- matchedSourceFile = sourceFile;
569
- matchedSourceField = sourceField;
517
+ matchedSource = source;
518
+ matchedName = name;
570
519
  break;
571
520
  }
572
521
  }
@@ -574,21 +523,17 @@ class FlowRunner extends events_1.EventEmitter {
574
523
  log.info({
575
524
  projectId: run.projectId,
576
525
  nodeId: node.id,
577
- matchedMode: matched ? (matched.variable ? 'variable' : 'field') : undefined,
578
- matchedVariable: matched?.variable,
579
- matchedField: matched?.field,
580
- matchedSourceFile,
581
- matchedSourceField,
526
+ matchedSource,
527
+ matchedName,
582
528
  matchedEquals: matched ? JSON.stringify(matched.equals) : undefined,
583
529
  actualValue: matched ? JSON.stringify(matchedActual) : undefined,
584
530
  goto,
585
531
  viaDefault: !matched,
586
532
  }, 'flow node system-logic branch evaluated');
587
- if (goto === null)
533
+ if (goto === null) {
588
534
  return { kind: 'ok', next: null };
589
- // Backward edge detection by history (codex review P1d) — node ids may
590
- // not be topologically ordered, so `goto < node.id` is unsafe. Visiting
591
- // the same id twice in this run = loop edge.
535
+ }
536
+ // Loop edge detection: target id is already in this run's history.
592
537
  const visited = new Set(run.state.history.map((h) => h.nodeId));
593
538
  const isBackward = visited.has(goto);
594
539
  if (isBackward) {
@@ -612,6 +557,10 @@ class FlowRunner extends events_1.EventEmitter {
612
557
  return { kind: 'ok', next: goto };
613
558
  }
614
559
  // ── Wait helpers ──────────────────────────────────────────────────────
560
+ /** Watch workflow_data.json for `task_progress[index].finish = true`. The
561
+ * LLM is told to flip this flag when its work is done. Any other write to
562
+ * workflow_data (variable updates, etc.) also triggers the watcher; the
563
+ * 50ms debounce + finish-check makes that cheap. */
615
564
  waitForTaskFinish(run, taskIndex, timeoutMs) {
616
565
  return new Promise((resolve) => {
617
566
  let settled = false;
@@ -623,17 +572,22 @@ class FlowRunner extends events_1.EventEmitter {
623
572
  resolve(v);
624
573
  };
625
574
  run.waitResolve = settle;
626
- // Initial check finish may have raced ahead before we attached.
627
- try {
628
- const todo = (0, store_1.readTaskTodo)(run.folderPath);
629
- if (todo.tasks[taskIndex]?.finish === true) {
630
- log.info({ projectId: run.projectId, taskIndex, via: 'initial-check' }, 'flow task_todo finish detected');
631
- settle('finished');
632
- return;
575
+ const checkFinish = () => {
576
+ try {
577
+ const d = (0, store_1.readWorkflowData)(run.folderPath);
578
+ return d.task_progress[taskIndex]?.finish === true;
579
+ }
580
+ catch {
581
+ return false;
633
582
  }
583
+ };
584
+ // Initial check — finish may have raced ahead before we attached.
585
+ if (checkFinish()) {
586
+ log.info({ projectId: run.projectId, taskIndex, via: 'initial-check' }, 'flow task_progress finish detected');
587
+ settle('finished');
588
+ return;
634
589
  }
635
- catch { /* ignore */ }
636
- const filePath = (0, store_1.taskTodoPath)(run.folderPath);
590
+ const filePath = (0, store_1.workflowDataPath)(run.folderPath);
637
591
  try {
638
592
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
639
593
  }
@@ -648,34 +602,21 @@ class FlowRunner extends events_1.EventEmitter {
648
602
  run.watcherDebounce = null;
649
603
  if (settled)
650
604
  return;
651
- try {
652
- const todo = (0, store_1.readTaskTodo)(run.folderPath);
653
- if (todo.tasks[taskIndex]?.finish === true) {
654
- log.info({ projectId: run.projectId, taskIndex, via: 'watcher' }, 'flow task_todo finish detected');
655
- settle('finished');
656
- }
605
+ if (checkFinish()) {
606
+ log.info({ projectId: run.projectId, taskIndex, via: 'watcher' }, 'flow task_progress finish detected');
607
+ settle('finished');
657
608
  }
658
- catch { /* keep waiting */ }
659
609
  }, 50);
660
610
  });
661
611
  }
662
612
  catch (err) {
663
- log.warn({ projectId: run.projectId, err: err instanceof Error ? err.message : String(err) }, 'task_todo watch failed — falling back to polling');
664
- // Fallback: 500ms poll
613
+ log.warn({ projectId: run.projectId, err: err instanceof Error ? err.message : String(err) }, 'workflow_data watch failed — falling back to polling');
665
614
  const poll = setInterval(() => {
666
- try {
667
- const todo = (0, store_1.readTaskTodo)(run.folderPath);
668
- if (todo.tasks[taskIndex]?.finish === true) {
669
- clearInterval(poll);
670
- settle('finished');
671
- }
615
+ if (checkFinish()) {
616
+ clearInterval(poll);
617
+ settle('finished');
672
618
  }
673
- catch { /* ignore */ }
674
619
  }, 500);
675
- // Tie poll to settle: we can't directly cancel here, but settle's
676
- // clearWaiters won't reach it. Wrap by mirroring as a fake watcher
677
- // via run.timeoutTimer ergonomics — simpler: stash on run object.
678
- // For phase-1 acceptable, just accept the small leak past timeout.
679
620
  run._poll = poll;
680
621
  }
681
622
  run.timeoutTimer = setTimeout(() => settle('timeout'), timeoutMs);
@@ -721,5 +662,8 @@ class FlowRunner extends events_1.EventEmitter {
721
662
  }
722
663
  }
723
664
  exports.FlowRunner = FlowRunner;
665
+ // Suppress unused-export warnings — clearFlowState may be used by callers we
666
+ // don't see (e.g. project deletion cleanup).
667
+ void store_1.clearFlowState;
724
668
  exports.flowRunner = new FlowRunner();
725
669
  //# sourceMappingURL=runner.js.map