@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.
- package/backend/dist/flows/runner.d.ts +4 -11
- package/backend/dist/flows/runner.d.ts.map +1 -1
- package/backend/dist/flows/runner.js +216 -272
- package/backend/dist/flows/runner.js.map +1 -1
- package/backend/dist/flows/store.d.ts +22 -18
- package/backend/dist/flows/store.d.ts.map +1 -1
- package/backend/dist/flows/store.js +68 -60
- package/backend/dist/flows/store.js.map +1 -1
- package/backend/dist/flows/types.d.ts +103 -58
- package/backend/dist/flows/types.d.ts.map +1 -1
- package/backend/dist/flows/types.js +20 -8
- package/backend/dist/flows/types.js.map +1 -1
- package/backend/dist/routes/flows.d.ts.map +1 -1
- package/backend/dist/routes/flows.js +197 -96
- package/backend/dist/routes/flows.js.map +1 -1
- package/frontend/dist/assets/{ChatOverlay-CGs4pH85.js → ChatOverlay-DsfZXjjz.js} +1 -1
- package/frontend/dist/assets/{GraphPreview-C9IEh8-V.js → GraphPreview-BGZHA8KW.js} +1 -1
- package/frontend/dist/assets/{MobilePage-BIiXwC0r.js → MobilePage-B-FiVfUE.js} +3 -3
- package/frontend/dist/assets/{OfficePreview-DFO94qTw.js → OfficePreview-CvHPeAlI.js} +2 -2
- package/frontend/dist/assets/{PdfPreview-QbDUi7dV.js → PdfPreview-D1OVG7jG.js} +1 -1
- package/frontend/dist/assets/{ProjectPage-B6L7cAC5.js → ProjectPage-CyKEenc5.js} +5 -5
- package/frontend/dist/assets/{SettingsPage-w8_jSK67.js → SettingsPage-CIe3HZml.js} +1 -1
- package/frontend/dist/assets/{SkillHubPage-hGWa2TIm.js → SkillHubPage-CG1-G4Pf.js} +1 -1
- package/frontend/dist/assets/{chevron-down-IcohQuLb.js → chevron-down-CS7uu2Ol.js} +1 -1
- package/frontend/dist/assets/{index-BxEZe7dG.js → index-BThL9NV3.js} +2 -2
- package/frontend/dist/assets/index-BkQ6KI1l.css +1 -0
- package/frontend/dist/assets/{index-Cx9N3v4o.js → index-DCc5jmus.js} +1 -1
- package/frontend/dist/assets/{index-CqiN8JNB.js → index-DN91i4kg.js} +1 -1
- package/frontend/dist/assets/{jszip.min-dTvFz_Ar.js → jszip.min-DHcltCpp.js} +1 -1
- package/frontend/dist/assets/{select-CQRCt-uM.js → select-C09dwvOR.js} +1 -1
- package/frontend/dist/assets/{user-CfulB_DL.js → user-Bj8X-WCQ.js} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- 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
|
-
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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(
|
|
51
|
+
const safe = text.replace(/[\x1b\r]/g, '');
|
|
49
52
|
return `\x1b[200~${safe}\x1b[201~\r`;
|
|
50
53
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
391
|
+
let submitted;
|
|
350
392
|
try {
|
|
351
|
-
|
|
393
|
+
submitted = await new Promise((resolve, reject) => {
|
|
352
394
|
run.userInputResolve = resolve;
|
|
353
395
|
run.userInputReject = reject;
|
|
354
396
|
});
|
|
355
397
|
}
|
|
356
|
-
catch
|
|
357
|
-
// Reject via abort() — outer handler does finalize.
|
|
398
|
+
catch {
|
|
358
399
|
return { kind: 'pause' };
|
|
359
400
|
}
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
for (const
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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.
|
|
402
|
-
const
|
|
403
|
-
|
|
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
|
-
//
|
|
440
|
-
const
|
|
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/
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
const
|
|
450
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
534
|
-
let
|
|
535
|
-
let
|
|
494
|
+
let matchedActual;
|
|
495
|
+
let matchedSource;
|
|
496
|
+
let matchedName;
|
|
536
497
|
for (const rule of node.branches) {
|
|
537
498
|
let actual;
|
|
538
|
-
let
|
|
539
|
-
let
|
|
499
|
+
let source;
|
|
500
|
+
let name;
|
|
540
501
|
if (rule.variable) {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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.
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
569
|
-
|
|
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
|
-
|
|
578
|
-
|
|
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
|
-
|
|
590
|
-
//
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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) }, '
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|