@tom2012/cc-web 2026.5.13-a → 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 +190 -381
  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 -75
  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 +184 -146
  15. package/backend/dist/routes/flows.js.map +1 -1
  16. package/frontend/dist/assets/{ChatOverlay-DeV9Kxv1.js → ChatOverlay-DsfZXjjz.js} +1 -1
  17. package/frontend/dist/assets/{GraphPreview-DKBe95dN.js → GraphPreview-BGZHA8KW.js} +1 -1
  18. package/frontend/dist/assets/{MobilePage-l30eYbBv.js → MobilePage-B-FiVfUE.js} +3 -3
  19. package/frontend/dist/assets/{OfficePreview-CZZ5h3xg.js → OfficePreview-CvHPeAlI.js} +2 -2
  20. package/frontend/dist/assets/{PdfPreview-Db1BlGXF.js → PdfPreview-D1OVG7jG.js} +1 -1
  21. package/frontend/dist/assets/{ProjectPage-BSuP0OrL.js → ProjectPage-CyKEenc5.js} +5 -5
  22. package/frontend/dist/assets/{SettingsPage-B9iJQRJi.js → SettingsPage-CIe3HZml.js} +1 -1
  23. package/frontend/dist/assets/{SkillHubPage-CDdKniVk.js → SkillHubPage-CG1-G4Pf.js} +1 -1
  24. package/frontend/dist/assets/{chevron-down-CO9XV3WE.js → chevron-down-CS7uu2Ol.js} +1 -1
  25. package/frontend/dist/assets/{index-CHWSKceq.js → index-BThL9NV3.js} +2 -2
  26. package/frontend/dist/assets/index-BkQ6KI1l.css +1 -0
  27. package/frontend/dist/assets/{index-sRc5noTy.js → index-DCc5jmus.js} +1 -1
  28. package/frontend/dist/assets/{index-CwqaFIAN.js → index-DN91i4kg.js} +1 -1
  29. package/frontend/dist/assets/{jszip.min-DQDZjRrf.js → jszip.min-DHcltCpp.js} +1 -1
  30. package/frontend/dist/assets/{select-D1f26o6o.js → select-C09dwvOR.js} +1 -1
  31. package/frontend/dist/assets/{user-CCZuBByN.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-RmHssrlH.css +0 -1
@@ -40,135 +40,115 @@ const events_1 = require("events");
40
40
  const uuid_1 = require("uuid");
41
41
  const logger_1 = require("../logger");
42
42
  const store_1 = require("./store");
43
- const types_1 = require("./types");
44
43
  const log = (0, logger_1.modLogger)('flow-runner');
45
- /** Build a bracketed-paste payload that the LLM CLI submits as one chat
46
- * message. The CR at the end triggers Enter; embedded paste markers in
47
- * 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. */
48
50
  function buildPaste(text) {
49
- const safe = text.replace(/\x1b\[20[01]~/g, '');
51
+ const safe = text.replace(/[\x1b\r]/g, '');
50
52
  return `\x1b[200~${safe}\x1b[201~\r`;
51
53
  }
52
- /** Substitute `{{file:relpath}}` tokens with the file's UTF-8 content.
53
- * Missing files render as `[ERROR reading <path>: <reason>]` — the runner
54
- * separately surfaces read failures via provider-aware error routing
55
- * before we get here, so this substitution path is only a defense for
56
- * unexpected misses. */
57
- function renderTemplate(folderPath, tpl) {
58
- return tpl.replace(/\{\{file:([^}]+)\}\}/g, (_m, rel) => {
59
- const abs = (0, store_1.safeProjectPath)(folderPath, rel.trim());
60
- if (!abs)
61
- return `[ERROR unsafe path rejected: ${rel}]`;
62
- try {
63
- return fs.readFileSync(abs, 'utf-8');
64
- }
65
- catch (err) {
66
- return `[ERROR reading ${rel}: ${err instanceof Error ? err.message : 'unknown'}]`;
67
- }
68
- });
69
- }
54
+ // ── Value rendering & sanitization ────────────────────────────────────────
70
55
  /** Strip terminal control bytes that would corrupt bracketed-paste mode or
71
56
  * Ink TUI state when the value is later injected into a prompt. ESC sequences
72
57
  * (incl. paste-mode markers) can confuse the agent's input parser; bare CR
73
58
  * prematurely closes paste mode. We keep LF and TAB so multi-line content
74
59
  * renders normally. */
75
- function sanitizeVarValue(s) {
60
+ function sanitizeForPrompt(s) {
76
61
  return s.replace(/[\x1b\r]/g, '');
77
62
  }
78
- /** Read a flow variable's current value from its file. Returns the empty
79
- * string if the file is missing, unparseable, or doesn't contain the key.
80
- * Non-string scalars are JSON-stringified for display. */
81
- function readVariableValue(folderPath, v) {
82
- const file = v.file || types_1.DEFAULT_VAR_FILE;
83
- const abs = (0, store_1.safeProjectPath)(folderPath, file);
84
- if (!abs)
85
- return '';
86
- try {
87
- const raw = fs.readFileSync(abs, 'utf-8');
88
- const obj = JSON.parse(raw);
89
- if (!obj || typeof obj !== 'object' || Array.isArray(obj))
90
- return '';
91
- const val = obj[v.name];
92
- if (val === undefined || val === null)
93
- return '';
94
- const str = typeof val === 'string' ? val : JSON.stringify(val);
95
- return sanitizeVarValue(str);
96
- }
97
- catch {
98
- return '';
99
- }
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));
100
74
  }
101
- /** Merge {[varName]: value} into the variable's file (read-modify-write).
102
- * Multiple variables may share one file, so we must preserve other keys. */
103
- function writeVariableValues(folderPath, byFile) {
104
- for (const [file, kv] of byFile) {
105
- const abs = (0, store_1.safeProjectPath)(folderPath, file);
106
- if (!abs)
107
- return { ok: false, error: `unsafe variable file path: ${file}` };
108
- let existing = {};
109
- try {
110
- const raw = fs.readFileSync(abs, 'utf-8');
111
- const parsed = JSON.parse(raw);
112
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
113
- existing = parsed;
114
- }
115
- }
116
- catch {
117
- /* missing or unparseable → start from {} */
118
- }
119
- Object.assign(existing, kv);
120
- try {
121
- fs.mkdirSync(path.dirname(abs), { recursive: true });
122
- fs.writeFileSync(abs, JSON.stringify(existing, null, 2));
123
- }
124
- catch (err) {
125
- return { ok: false, error: `failed to write ${file}: ${err instanceof Error ? err.message : 'unknown'}` };
126
- }
127
- }
128
- return { ok: true };
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()]);
89
+ });
129
90
  }
130
- /** Build the prompt prefix that surfaces current values of `referenceVariables`
131
- * to the LLM as a context block. Distinct from initVariables (which asks the
132
- * LLM to *produce* a value) — reference is read-only context. */
133
- function buildReferenceVarBlock(refNames, variables, folderPath) {
134
- if (refNames.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)
135
95
  return '';
136
- const byName = new Map(variables.map((v) => [v.name, v]));
137
- const lines = [];
138
- lines.push('──────── 流变量当前值 ────────');
139
- for (const name of refNames) {
96
+ const byName = new Map((def.variables ?? []).map((v) => [v.name, v]));
97
+ const lines = ['──────── 流变量当前值 ────────'];
98
+ for (const name of names) {
140
99
  const v = byName.get(name);
141
100
  if (!v)
142
101
  continue;
143
- const value = readVariableValue(folderPath, v);
144
102
  const meaning = v.description || '(无描述)';
145
103
  lines.push(`变量 \`${v.name}\`(含义:${meaning}):`);
146
- lines.push(value ? value : '(未设置)');
104
+ lines.push(formatValueForPrompt(data.variables[name]));
147
105
  lines.push('');
148
106
  }
149
107
  lines.push('');
150
108
  return lines.join('\n');
151
109
  }
152
- /** Build the prompt suffix that instructs the LLM to derive + persist each
153
- * variable named in `initVariables`. Skips unknown names defensively (route
154
- * validator already rejects them at save time). */
155
- function buildInitVarBlock(initNames, variables) {
156
- if (initNames.length === 0)
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)
157
114
  return '';
158
- const byName = new Map(variables.map((v) => [v.name, v]));
159
- const lines = [];
160
- lines.push('\n\n──────── 变量初始化指令 ────────');
161
- lines.push('完成本任务后,请按下面列出的含义判断每个变量的值,');
162
- lines.push('用 Write 或 Edit 工具把变量值写入到对应 JSON 文件的顶层字段(字段名 = 变量名)。');
163
- lines.push('如果目标文件不存在请新建;如果已存在请保留其他字段。\n');
164
- for (const name of initNames) {
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('');
125
+ }
126
+ lines.push('');
127
+ return lines.join('\n');
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) {
165
144
  const v = byName.get(name);
166
145
  if (!v)
167
146
  continue;
168
- lines.push(`- \`${v.name}\` → 写入文件 \`${v.file}\` 的顶层 \`${v.name}\` 字段。含义:${v.description || '(无描述)'}`);
147
+ lines.push(`- \`${v.name}\` → 写入 variables.${v.name}。含义:${v.description || '(无描述)'}`);
169
148
  }
170
149
  return lines.join('\n');
171
150
  }
151
+ // ── Branch evaluation ─────────────────────────────────────────────────────
172
152
  /** Loose equality for branch evaluation. JSON outputs from LLMs frequently
173
153
  * type-shift (`true` → `"true"`, `1` → `"1"`); branch authors typically
174
154
  * configure the typed primitive, so we coerce common cases. */
@@ -193,34 +173,6 @@ function branchMatches(value, expected) {
193
173
  }
194
174
  return false;
195
175
  }
196
- function readInputs(folderPath, inputs) {
197
- for (const inp of inputs) {
198
- const abs = (0, store_1.safeProjectPath)(folderPath, inp.path);
199
- if (!abs) {
200
- return {
201
- ok: false,
202
- failingProvider: inp.provider,
203
- error: `unsafe path rejected: ${inp.path}`,
204
- };
205
- }
206
- try {
207
- const raw = fs.readFileSync(abs, 'utf-8');
208
- // Best-effort JSON parse — non-JSON inputs (e.g. bibtex) pass through
209
- // here as long as the file exists; a stricter parse, if needed, lives
210
- // in the consuming node (e.g. system-logic parses JSON itself).
211
- if (inp.path.endsWith('.json'))
212
- JSON.parse(raw);
213
- }
214
- catch (err) {
215
- return {
216
- ok: false,
217
- failingProvider: inp.provider,
218
- error: err instanceof Error ? err.message : String(err),
219
- };
220
- }
221
- }
222
- return { ok: true };
223
- }
224
176
  class FlowRunner extends events_1.EventEmitter {
225
177
  constructor() {
226
178
  super(...arguments);
@@ -230,7 +182,6 @@ class FlowRunner extends events_1.EventEmitter {
230
182
  setPromptInjector(fn) {
231
183
  this.injector = fn;
232
184
  }
233
- /** Returns null if a flow is already running for this project. */
234
185
  start(projectId, folderPath, flowDef, flowFilename) {
235
186
  if (this.active.has(projectId)) {
236
187
  return { ok: false, reason: 'already-running' };
@@ -238,7 +189,9 @@ class FlowRunner extends events_1.EventEmitter {
238
189
  const startNode = flowDef.nodes.find((n) => n.id === flowDef.entryNodeId);
239
190
  if (!startNode)
240
191
  return { ok: false, reason: 'entry-node-not-found' };
241
- (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);
242
195
  const state = {
243
196
  flowId: flowDef.id,
244
197
  flowFilename,
@@ -263,7 +216,6 @@ class FlowRunner extends events_1.EventEmitter {
263
216
  userInputResolve: null,
264
217
  userInputReject: null,
265
218
  currentTaskIndex: null,
266
- pendingLlmError: null,
267
219
  };
268
220
  this.active.set(projectId, run);
269
221
  this.emit('state', { projectId, state });
@@ -271,27 +223,19 @@ class FlowRunner extends events_1.EventEmitter {
271
223
  projectId,
272
224
  flowId: flowDef.id,
273
225
  flowName: flowDef.name,
226
+ schemaVersion: flowDef.schemaVersion,
274
227
  entryNodeId: flowDef.entryNodeId,
275
228
  nodeCount: flowDef.nodes.length,
229
+ constantCount: (flowDef.constants ?? []).length,
230
+ variableCount: (flowDef.variables ?? []).length,
276
231
  runId: state.runId,
277
232
  }, 'flow start');
278
- // Fire-and-forget; the loop persists state on each transition.
279
233
  void this.runLoop(run).catch((err) => {
280
234
  log.error({ projectId, err: err instanceof Error ? err.message : String(err) }, 'run loop crashed');
281
235
  this.finalize(run, 'failed', err instanceof Error ? err.message : String(err));
282
236
  });
283
237
  return { ok: true, state };
284
238
  }
285
- /**
286
- * Resume a paused run by re-executing the current node. Caller intent:
287
- * - timeout → re-inject prompt, wait again
288
- * - max-retries-exceeded → reset that node's loop counter so user gets
289
- * a fresh allotment (otherwise resume would immediately hit the same
290
- * cap and pause again)
291
- * - file-read-error → user fixed the file; re-read succeeds and node
292
- * proceeds normally
293
- * - awaiting-user-input → wrong path; caller should use submitUserInput
294
- */
295
239
  resume(projectId) {
296
240
  const run = this.active.get(projectId);
297
241
  if (!run)
@@ -330,9 +274,6 @@ class FlowRunner extends events_1.EventEmitter {
330
274
  const hadTaskWait = !!wait;
331
275
  run.userInputResolve = null;
332
276
  run.userInputReject = null;
333
- // settle() inside waitForTaskFinish calls clearWaiters internally, so
334
- // we don't need to call it here for the wait path. For the user-input
335
- // path we still need finalize to clean up (no watcher/timer there).
336
277
  wait?.('aborted');
337
278
  userReject?.('aborted');
338
279
  log.info({ projectId, currentNodeId: run.state.currentNodeId, hadTaskWait, hadUserInputWait }, 'flow abort');
@@ -347,8 +288,6 @@ class FlowRunner extends events_1.EventEmitter {
347
288
  const resolve = run.userInputResolve;
348
289
  run.userInputResolve = null;
349
290
  run.userInputReject = null;
350
- // Log keys + value lengths only — field values may contain user research
351
- // goals / unpublished hypotheses that we don't want in plaintext logs.
352
291
  log.info({
353
292
  projectId,
354
293
  currentNodeId: run.state.currentNodeId,
@@ -390,7 +329,6 @@ class FlowRunner extends events_1.EventEmitter {
390
329
  : outcome.kind === 'retry' ? 'retry'
391
330
  : 'error';
392
331
  if (outcome.kind === 'pause' || outcome.kind === 'error') {
393
- // executeNode already set status/pauseReason; persist + bail out.
394
332
  this.persist(run);
395
333
  return;
396
334
  }
@@ -402,7 +340,6 @@ class FlowRunner extends events_1.EventEmitter {
402
340
  return;
403
341
  }
404
342
  }
405
- // 'retry' just persists; loop continues with same state.currentNodeId
406
343
  }
407
344
  }
408
345
  // ── Node executors ────────────────────────────────────────────────────
@@ -418,24 +355,31 @@ class FlowRunner extends events_1.EventEmitter {
418
355
  return { kind: 'error', message: `unknown node kind: ${node.kind}` };
419
356
  }
420
357
  async executeUserInput(run, node) {
421
- // Pre-read values for fields with bindVariable so the frontend can show
422
- // them read-only without an extra fetch.
423
- const variables = run.flowDef.variables ?? [];
424
- const variableValues = {};
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 = {};
425
363
  for (const field of node.userInputSchema.fields) {
426
- if (!field.bindVariable)
427
- continue;
428
- const v = variables.find((x) => x.name === field.bindVariable);
429
- if (!v)
430
- continue;
431
- variableValues[field.key] = readVariableValue(run.folderPath, v);
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
+ }
432
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;
433
377
  run.state.status = 'paused';
434
378
  run.state.pauseReason = 'awaiting-user-input';
435
379
  run.state.pendingUserInput = {
436
380
  nodeId: node.id,
437
381
  fields: node.userInputSchema.fields,
438
- variableValues: Object.keys(variableValues).length > 0 ? variableValues : undefined,
382
+ contextValues,
439
383
  };
440
384
  this.persist(run);
441
385
  this.emit('user-input', { projectId: run.projectId, nodeId: node.id, fields: node.userInputSchema.fields });
@@ -443,78 +387,33 @@ class FlowRunner extends events_1.EventEmitter {
443
387
  projectId: run.projectId,
444
388
  nodeId: node.id,
445
389
  fieldKeys: node.userInputSchema.fields.map((f) => f.key),
446
- outputCount: node.outputs.length,
447
390
  }, 'flow node user-input awaiting');
448
- let data;
391
+ let submitted;
449
392
  try {
450
- data = await new Promise((resolve, reject) => {
393
+ submitted = await new Promise((resolve, reject) => {
451
394
  run.userInputResolve = resolve;
452
395
  run.userInputReject = reject;
453
396
  });
454
397
  }
455
- catch (err) {
456
- // Reject via abort() — outer handler does finalize.
398
+ catch {
457
399
  return { kind: 'pause' };
458
400
  }
459
- // For fields with bindVariable (read-only display), substitute the
460
- // current variable value rather than trusting client-supplied data
461
- // the frontend disables those inputs but a malicious client could still
462
- // send any string.
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 = {};
463
407
  for (const field of node.userInputSchema.fields) {
464
- if (!field.bindVariable)
465
- continue;
466
- const v = variables.find((x) => x.name === field.bindVariable);
467
- if (!v)
468
- continue;
469
- data[field.key] = readVariableValue(run.folderPath, v);
470
- }
471
- // Write outputs: synthesize a JSON object from user fields and write to
472
- // each declared output file. For Phase 1, multi-output user-input nodes
473
- // get the same payload written to each — keeps the schema simple.
474
- const payload = {};
475
- for (const field of node.userInputSchema.fields)
476
- payload[field.key] = data[field.key] ?? '';
477
- // Merge values for fields with outputToVariable into the named variable's
478
- // file (read-modify-write — multiple variables may share one file).
479
- const variableUpdates = new Map();
480
- for (const field of node.userInputSchema.fields) {
481
- if (!field.outputToVariable)
482
- continue;
483
- const v = variables.find((x) => x.name === field.outputToVariable);
484
- if (!v)
485
- continue;
486
- const file = v.file || types_1.DEFAULT_VAR_FILE;
487
- if (!variableUpdates.has(file))
488
- variableUpdates.set(file, {});
489
- variableUpdates.get(file)[v.name] = data[field.key] ?? '';
490
- }
491
- if (variableUpdates.size > 0) {
492
- const wr = writeVariableValues(run.folderPath, variableUpdates);
493
- if (!wr.ok) {
494
- run.state.status = 'failed';
495
- run.state.pauseReason = null;
496
- run.state.pauseDetail = wr.error ?? 'failed to write variables';
497
- return { kind: 'error', message: run.state.pauseDetail };
408
+ if (field.outputVariable) {
409
+ variableUpdates[field.outputVariable] = submitted[field.key] ?? '';
498
410
  }
411
+ // bindVariable / bindConstant fields contribute nothing to writes —
412
+ // they're read-only displays.
499
413
  }
500
- for (const out of node.outputs) {
501
- const abs = (0, store_1.safeProjectPath)(run.folderPath, out.path);
502
- if (!abs) {
503
- run.state.status = 'failed';
504
- run.state.pauseReason = null;
505
- run.state.pauseDetail = `unsafe output path rejected: ${out.path}`;
506
- return { kind: 'error', message: run.state.pauseDetail };
507
- }
508
- try {
509
- fs.mkdirSync(path.dirname(abs), { recursive: true });
510
- fs.writeFileSync(abs, JSON.stringify(payload, null, 2));
511
- }
512
- catch (err) {
513
- run.state.status = 'failed';
514
- run.state.pauseReason = null;
515
- run.state.pauseDetail = `failed to write ${out.path}: ${err instanceof Error ? err.message : 'unknown'}`;
516
- return { kind: 'error', message: run.state.pauseDetail };
517
- }
414
+ if (Object.keys(variableUpdates).length > 0) {
415
+ fresh.variables = { ...fresh.variables, ...variableUpdates };
416
+ (0, store_1.writeWorkflowData)(run.folderPath, fresh);
518
417
  }
519
418
  run.state.status = 'running';
520
419
  run.state.pauseReason = null;
@@ -523,7 +422,7 @@ class FlowRunner extends events_1.EventEmitter {
523
422
  log.info({
524
423
  projectId: run.projectId,
525
424
  nodeId: node.id,
526
- outputsWritten: node.outputs.map((o) => o.path),
425
+ wroteVariables: Object.keys(variableUpdates),
527
426
  next: node.next,
528
427
  }, 'flow node user-input completed');
529
428
  return { kind: 'ok', next: node.next };
@@ -532,68 +431,37 @@ class FlowRunner extends events_1.EventEmitter {
532
431
  if (!this.injector) {
533
432
  return { kind: 'error', message: 'no prompt injector configured' };
534
433
  }
535
- // 1. Read & validate inputs (provider-aware error routing)
536
- const readResult = readInputs(run.folderPath, node.inputs);
537
- if (!readResult.ok) {
538
- log.warn({
539
- projectId: run.projectId,
540
- nodeId: node.id,
541
- failingProvider: readResult.failingProvider,
542
- error: readResult.error,
543
- inputs: node.inputs.map((i) => i.path),
544
- }, 'flow node llm input read failed');
545
- if (readResult.failingProvider === 'user') {
546
- run.state.status = 'paused';
547
- run.state.pauseReason = 'user-file-read-error';
548
- run.state.pauseDetail = `failed to read input file (provider=user): ${readResult.error}`;
549
- this.emit('error', {
550
- projectId: run.projectId,
551
- nodeId: node.id,
552
- reason: 'user-file-read-error',
553
- detail: run.state.pauseDetail,
554
- });
555
- return { kind: 'pause' };
556
- }
557
- // provider=llm or system — stash error to inject in the next prompt to
558
- // the LLM. Since the input was supposedly produced by an upstream LLM
559
- // node, we still send the prompt to *this* node's LLM but with an
560
- // explanatory wrapper, asking it to handle/repair.
561
- run.pendingLlmError = {
562
- path: node.inputs.find((i) => i.provider !== 'user')?.path ?? '?',
563
- error: readResult.error ?? 'unknown',
564
- };
565
- }
566
- // 2. task_todo entry
567
- const taskIndex = (0, store_1.appendTaskTodo)(run.folderPath, {
568
- 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,
569
437
  name: node.name,
570
438
  finish: false,
571
439
  });
572
440
  run.currentTaskIndex = taskIndex;
573
- // 3. Build prompt
574
- const errorBlock = run.pendingLlmError
575
- ? `\n\n[文件读取错误] 上游产物 ${run.pendingLlmError.path} 解析失败:${run.pendingLlmError.error}\n请先修复该文件再继续本任务。\n`
576
- : '';
577
- run.pendingLlmError = null;
441
+ // 2. Build prompt: header + read-context blocks + body + write block.
442
+ const data = (0, store_1.readWorkflowData)(run.folderPath);
578
443
  const taskHeader = `当前任务 id=${node.id},名为「${node.name}」。\n` +
579
- `完成后请把 .ccweb/task_todo.json 中索引 ${taskIndex} 处 entry 的 finish 字段改为 true(用 Edit/Write 工具直接更新该 JSON 文件)。\n` +
580
- `\n──────── 任务正文 ────────\n`;
581
- const refVarBlock = buildReferenceVarBlock(node.referenceVariables ?? [], run.flowDef.variables ?? [], run.folderPath);
582
- const body = renderTemplate(run.folderPath, node.promptTemplate);
583
- const initVarBlock = buildInitVarBlock(node.initVariables ?? [], run.flowDef.variables ?? []);
584
- const fullPrompt = `${taskHeader}${refVarBlock}${body}${initVarBlock}${errorBlock}`;
585
- // 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.
586
453
  this.injector(run.projectId, buildPaste(fullPrompt));
587
454
  log.info({
588
455
  projectId: run.projectId,
589
456
  nodeId: node.id,
590
457
  taskIndex,
591
458
  promptSize: fullPrompt.length,
592
- hasErrorBlock: errorBlock.length > 0,
459
+ readVariables: node.readVariables ?? [],
460
+ readConstants: node.readConstants ?? [],
461
+ writeVariables: node.writeVariables ?? [],
593
462
  timeoutSec: node.timeoutSec,
594
- inputs: node.inputs.map((i) => i.path),
595
463
  }, 'flow node llm prompt injected');
596
- // 5. Wait for task_todo finish:true OR timeout
464
+ // 4. Wait for task_progress[taskIndex].finish OR timeout.
597
465
  const outcome = await this.waitForTaskFinish(run, taskIndex, node.timeoutSec * 1000);
598
466
  log.info({ projectId: run.projectId, nodeId: node.id, taskIndex, outcome }, 'flow node llm wait outcome');
599
467
  if (outcome === 'aborted' || outcome === 'paused') {
@@ -612,87 +480,33 @@ class FlowRunner extends events_1.EventEmitter {
612
480
  log.warn({ projectId: run.projectId, nodeId: node.id, timeoutSec: node.timeoutSec, taskIndex }, 'flow node llm timeout');
613
481
  return { kind: 'pause' };
614
482
  }
615
- // 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.
616
488
  run.currentTaskIndex = null;
617
489
  return { kind: 'ok', next: node.next };
618
490
  }
619
491
  async executeSystemLogic(run, node) {
620
- // Mixed-mode branch evaluation: each branch is either variable-mode
621
- // (resolve via flow.variables → file+field) or field-mode (legacy:
622
- // node.inputs[0] + branch.field). Files are cached per evaluation pass
623
- // so multi-branch flows touching the same file pay one read each.
624
- const variables = run.flowDef.variables ?? [];
625
- const varByName = new Map(variables.map((v) => [v.name, v]));
626
- const fileCache = new Map();
627
- /** Helper: read+parse a file (provider-aware on read error → pause).
628
- * Returns null when an error has been recorded and caller should
629
- * return pause; otherwise returns the parsed object. */
630
- const readFileForBranch = (relPath, provider) => {
631
- const cached = fileCache.get(relPath);
632
- if (cached)
633
- return cached;
634
- const abs = (0, store_1.safeProjectPath)(run.folderPath, relPath);
635
- if (!abs) {
636
- run.state.status = 'paused';
637
- run.state.pauseReason = 'user-file-read-error';
638
- run.state.pauseDetail = `unsafe input path rejected: ${relPath}`;
639
- return 'pause';
640
- }
641
- try {
642
- const parsed = JSON.parse(fs.readFileSync(abs, 'utf-8'));
643
- const obj = (parsed && typeof parsed === 'object') ? parsed : {};
644
- fileCache.set(relPath, obj);
645
- return obj;
646
- }
647
- catch (err) {
648
- const msg = err instanceof Error ? err.message : String(err);
649
- log.warn({ projectId: run.projectId, nodeId: node.id, path: relPath, provider, error: msg }, 'flow node system-logic input parse failed');
650
- run.state.status = 'paused';
651
- run.state.pauseReason = provider === 'user' ? 'user-file-read-error' : 'llm-file-read-error';
652
- run.state.pauseDetail = `failed to parse ${relPath} (provider=${provider}): ${msg}`;
653
- if (provider !== 'user')
654
- run.pendingLlmError = { path: relPath, error: msg };
655
- this.emit('error', {
656
- projectId: run.projectId,
657
- nodeId: node.id,
658
- reason: run.state.pauseReason,
659
- detail: run.state.pauseDetail,
660
- });
661
- return 'pause';
662
- }
663
- };
664
- // Evaluate branches with loose comparison — LLM often writes JSON
665
- // booleans as strings ("true"/"false") or numbers; branch authors
666
- // probably configured the typed primitive.
492
+ const data = (0, store_1.readWorkflowData)(run.folderPath);
667
493
  let matched = null;
668
- let matchedActual = undefined;
669
- let matchedSourceFile;
670
- let matchedSourceField;
494
+ let matchedActual;
495
+ let matchedSource;
496
+ let matchedName;
671
497
  for (const rule of node.branches) {
672
498
  let actual;
673
- let sourceFile;
674
- let sourceField;
499
+ let source;
500
+ let name;
675
501
  if (rule.variable) {
676
- const v = varByName.get(rule.variable);
677
- if (!v)
678
- continue; // already rejected at validation; defensive skip
679
- const obj = readFileForBranch(v.file, 'llm');
680
- if (obj === 'pause')
681
- return { kind: 'pause' };
682
- actual = obj[v.name];
683
- sourceFile = v.file;
684
- sourceField = v.name;
502
+ actual = data.variables[rule.variable];
503
+ source = 'variable';
504
+ name = rule.variable;
685
505
  }
686
- else if (rule.field) {
687
- const legacyInp = node.inputs[0];
688
- if (!legacyInp)
689
- return { kind: 'error', message: `node ${node.id} field-mode branch needs inputs[0]` };
690
- const obj = readFileForBranch(legacyInp.path, legacyInp.provider);
691
- if (obj === 'pause')
692
- return { kind: 'pause' };
693
- actual = obj[rule.field];
694
- sourceFile = legacyInp.path;
695
- sourceField = rule.field;
506
+ else if (rule.constant) {
507
+ actual = data.constants[rule.constant];
508
+ source = 'constant';
509
+ name = rule.constant;
696
510
  }
697
511
  else {
698
512
  continue;
@@ -700,8 +514,8 @@ class FlowRunner extends events_1.EventEmitter {
700
514
  if (branchMatches(actual, rule.equals)) {
701
515
  matched = rule;
702
516
  matchedActual = actual;
703
- matchedSourceFile = sourceFile;
704
- matchedSourceField = sourceField;
517
+ matchedSource = source;
518
+ matchedName = name;
705
519
  break;
706
520
  }
707
521
  }
@@ -709,21 +523,17 @@ class FlowRunner extends events_1.EventEmitter {
709
523
  log.info({
710
524
  projectId: run.projectId,
711
525
  nodeId: node.id,
712
- matchedMode: matched ? (matched.variable ? 'variable' : 'field') : undefined,
713
- matchedVariable: matched?.variable,
714
- matchedField: matched?.field,
715
- matchedSourceFile,
716
- matchedSourceField,
526
+ matchedSource,
527
+ matchedName,
717
528
  matchedEquals: matched ? JSON.stringify(matched.equals) : undefined,
718
529
  actualValue: matched ? JSON.stringify(matchedActual) : undefined,
719
530
  goto,
720
531
  viaDefault: !matched,
721
532
  }, 'flow node system-logic branch evaluated');
722
- if (goto === null)
533
+ if (goto === null) {
723
534
  return { kind: 'ok', next: null };
724
- // Backward edge detection by history (codex review P1d) — node ids may
725
- // not be topologically ordered, so `goto < node.id` is unsafe. Visiting
726
- // the same id twice in this run = loop edge.
535
+ }
536
+ // Loop edge detection: target id is already in this run's history.
727
537
  const visited = new Set(run.state.history.map((h) => h.nodeId));
728
538
  const isBackward = visited.has(goto);
729
539
  if (isBackward) {
@@ -747,6 +557,10 @@ class FlowRunner extends events_1.EventEmitter {
747
557
  return { kind: 'ok', next: goto };
748
558
  }
749
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. */
750
564
  waitForTaskFinish(run, taskIndex, timeoutMs) {
751
565
  return new Promise((resolve) => {
752
566
  let settled = false;
@@ -758,17 +572,22 @@ class FlowRunner extends events_1.EventEmitter {
758
572
  resolve(v);
759
573
  };
760
574
  run.waitResolve = settle;
761
- // Initial check finish may have raced ahead before we attached.
762
- try {
763
- const todo = (0, store_1.readTaskTodo)(run.folderPath);
764
- if (todo.tasks[taskIndex]?.finish === true) {
765
- log.info({ projectId: run.projectId, taskIndex, via: 'initial-check' }, 'flow task_todo finish detected');
766
- settle('finished');
767
- return;
575
+ const checkFinish = () => {
576
+ try {
577
+ const d = (0, store_1.readWorkflowData)(run.folderPath);
578
+ return d.task_progress[taskIndex]?.finish === true;
768
579
  }
580
+ catch {
581
+ return false;
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;
769
589
  }
770
- catch { /* ignore */ }
771
- const filePath = (0, store_1.taskTodoPath)(run.folderPath);
590
+ const filePath = (0, store_1.workflowDataPath)(run.folderPath);
772
591
  try {
773
592
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
774
593
  }
@@ -783,34 +602,21 @@ class FlowRunner extends events_1.EventEmitter {
783
602
  run.watcherDebounce = null;
784
603
  if (settled)
785
604
  return;
786
- try {
787
- const todo = (0, store_1.readTaskTodo)(run.folderPath);
788
- if (todo.tasks[taskIndex]?.finish === true) {
789
- log.info({ projectId: run.projectId, taskIndex, via: 'watcher' }, 'flow task_todo finish detected');
790
- settle('finished');
791
- }
605
+ if (checkFinish()) {
606
+ log.info({ projectId: run.projectId, taskIndex, via: 'watcher' }, 'flow task_progress finish detected');
607
+ settle('finished');
792
608
  }
793
- catch { /* keep waiting */ }
794
609
  }, 50);
795
610
  });
796
611
  }
797
612
  catch (err) {
798
- log.warn({ projectId: run.projectId, err: err instanceof Error ? err.message : String(err) }, 'task_todo watch failed — falling back to polling');
799
- // 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');
800
614
  const poll = setInterval(() => {
801
- try {
802
- const todo = (0, store_1.readTaskTodo)(run.folderPath);
803
- if (todo.tasks[taskIndex]?.finish === true) {
804
- clearInterval(poll);
805
- settle('finished');
806
- }
615
+ if (checkFinish()) {
616
+ clearInterval(poll);
617
+ settle('finished');
807
618
  }
808
- catch { /* ignore */ }
809
619
  }, 500);
810
- // Tie poll to settle: we can't directly cancel here, but settle's
811
- // clearWaiters won't reach it. Wrap by mirroring as a fake watcher
812
- // via run.timeoutTimer ergonomics — simpler: stash on run object.
813
- // For phase-1 acceptable, just accept the small leak past timeout.
814
620
  run._poll = poll;
815
621
  }
816
622
  run.timeoutTimer = setTimeout(() => settle('timeout'), timeoutMs);
@@ -856,5 +662,8 @@ class FlowRunner extends events_1.EventEmitter {
856
662
  }
857
663
  }
858
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;
859
668
  exports.flowRunner = new FlowRunner();
860
669
  //# sourceMappingURL=runner.js.map