@tom2012/cc-web 2026.5.8-a → 2026.5.11-b

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 (40) hide show
  1. package/backend/dist/flows/runner.d.ts +44 -0
  2. package/backend/dist/flows/runner.d.ts.map +1 -0
  3. package/backend/dist/flows/runner.js +656 -0
  4. package/backend/dist/flows/runner.js.map +1 -0
  5. package/backend/dist/flows/store.d.ts +31 -0
  6. package/backend/dist/flows/store.d.ts.map +1 -0
  7. package/backend/dist/flows/store.js +201 -0
  8. package/backend/dist/flows/store.js.map +1 -0
  9. package/backend/dist/flows/types.d.ts +100 -0
  10. package/backend/dist/flows/types.d.ts.map +1 -0
  11. package/backend/dist/flows/types.js +9 -0
  12. package/backend/dist/flows/types.js.map +1 -0
  13. package/backend/dist/index.d.ts.map +1 -1
  14. package/backend/dist/index.js +7 -0
  15. package/backend/dist/index.js.map +1 -1
  16. package/backend/dist/routes/flows.d.ts +3 -0
  17. package/backend/dist/routes/flows.d.ts.map +1 -0
  18. package/backend/dist/routes/flows.js +244 -0
  19. package/backend/dist/routes/flows.js.map +1 -0
  20. package/frontend/dist/assets/{ChatOverlay-Dewml63P.js → ChatOverlay-DOPyrrXA.js} +2 -2
  21. package/frontend/dist/assets/{GraphPreview-DKqon8iY.js → GraphPreview-CKjnNwAU.js} +1 -1
  22. package/frontend/dist/assets/{MobilePage-0yihNWS5.js → MobilePage-Cv3IL4tK.js} +3 -3
  23. package/frontend/dist/assets/{OfficePreview-CLoz-Kzs.js → OfficePreview-C1KT27Ss.js} +2 -2
  24. package/frontend/dist/assets/{PdfPreview-ceFK86bz.js → PdfPreview-DPO8NU-g.js} +1 -1
  25. package/frontend/dist/assets/{ProjectPage-Dx8ti9Xg.js → ProjectPage-CCE5a09C.js} +5 -5
  26. package/frontend/dist/assets/SettingsPage-CSsVqoPw.js +13 -0
  27. package/frontend/dist/assets/{SkillHubPage-0i_Vbges.js → SkillHubPage-DTWHYFS5.js} +1 -1
  28. package/frontend/dist/assets/{chevron-down-CabXIthZ.js → chevron-down-Br-a5rS0.js} +1 -1
  29. package/frontend/dist/assets/{index-PkEuCOb6.js → index-BjokCGs1.js} +2 -2
  30. package/frontend/dist/assets/{index-DKiLzICV.js → index-CIhDjRmt.js} +1 -1
  31. package/frontend/dist/assets/{index-QPAdq047.js → index-Ci2OcLAl.js} +1 -1
  32. package/frontend/dist/assets/index-nnLME6Go.css +1 -0
  33. package/frontend/dist/assets/{jszip.min-C5eqdtGO.js → jszip.min-CNWZX5Wv.js} +1 -1
  34. package/frontend/dist/assets/{search-CO9hGSbE.js → search-B92cpQPd.js} +1 -1
  35. package/frontend/dist/assets/select-Bk7v5W9n.js +13 -0
  36. package/frontend/dist/index.html +2 -2
  37. package/package.json +1 -1
  38. package/frontend/dist/assets/SettingsPage-DqXMFZN7.js +0 -13
  39. package/frontend/dist/assets/index-BJDTvhqr.css +0 -1
  40. package/frontend/dist/assets/index-DFmoROkt.js +0 -13
@@ -0,0 +1,656 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.flowRunner = exports.FlowRunner = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const events_1 = require("events");
40
+ const uuid_1 = require("uuid");
41
+ const logger_1 = require("../logger");
42
+ const store_1 = require("./store");
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. */
47
+ function buildPaste(text) {
48
+ const safe = text.replace(/\x1b\[20[01]~/g, '');
49
+ return `\x1b[200~${safe}\x1b[201~\r`;
50
+ }
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
+ }
67
+ });
68
+ }
69
+ /** Loose equality for branch evaluation. JSON outputs from LLMs frequently
70
+ * type-shift (`true` → `"true"`, `1` → `"1"`); branch authors typically
71
+ * configure the typed primitive, so we coerce common cases. */
72
+ function branchMatches(value, expected) {
73
+ if (Object.is(value, expected))
74
+ return true;
75
+ if (typeof expected === 'boolean') {
76
+ if (typeof value === 'string') {
77
+ const v = value.toLowerCase().trim();
78
+ return (expected && (v === 'true' || v === '1' || v === 'yes')) ||
79
+ (!expected && (v === 'false' || v === '0' || v === 'no'));
80
+ }
81
+ if (typeof value === 'number')
82
+ return expected === (value !== 0);
83
+ }
84
+ if (typeof expected === 'number' && typeof value === 'string') {
85
+ const n = Number(value);
86
+ return !Number.isNaN(n) && n === expected;
87
+ }
88
+ if (typeof expected === 'string' && typeof value === 'number') {
89
+ return value.toString() === expected;
90
+ }
91
+ return false;
92
+ }
93
+ function readInputs(folderPath, inputs) {
94
+ for (const inp of inputs) {
95
+ const abs = (0, store_1.safeProjectPath)(folderPath, inp.path);
96
+ if (!abs) {
97
+ return {
98
+ ok: false,
99
+ failingProvider: inp.provider,
100
+ error: `unsafe path rejected: ${inp.path}`,
101
+ };
102
+ }
103
+ try {
104
+ const raw = fs.readFileSync(abs, 'utf-8');
105
+ // Best-effort JSON parse — non-JSON inputs (e.g. bibtex) pass through
106
+ // here as long as the file exists; a stricter parse, if needed, lives
107
+ // in the consuming node (e.g. system-logic parses JSON itself).
108
+ if (inp.path.endsWith('.json'))
109
+ JSON.parse(raw);
110
+ }
111
+ catch (err) {
112
+ return {
113
+ ok: false,
114
+ failingProvider: inp.provider,
115
+ error: err instanceof Error ? err.message : String(err),
116
+ };
117
+ }
118
+ }
119
+ return { ok: true };
120
+ }
121
+ class FlowRunner extends events_1.EventEmitter {
122
+ constructor() {
123
+ super(...arguments);
124
+ this.active = new Map();
125
+ this.injector = null;
126
+ }
127
+ setPromptInjector(fn) {
128
+ this.injector = fn;
129
+ }
130
+ /** Returns null if a flow is already running for this project. */
131
+ start(projectId, folderPath, flowDef) {
132
+ if (this.active.has(projectId)) {
133
+ return { ok: false, reason: 'already-running' };
134
+ }
135
+ const startNode = flowDef.nodes.find((n) => n.id === flowDef.entryNodeId);
136
+ if (!startNode)
137
+ return { ok: false, reason: 'entry-node-not-found' };
138
+ (0, store_1.resetTaskTodo)(folderPath);
139
+ const state = {
140
+ flowId: flowDef.id,
141
+ runId: (0, uuid_1.v4)(),
142
+ startedAt: Date.now(),
143
+ status: 'running',
144
+ currentNodeId: flowDef.entryNodeId,
145
+ loopCounters: {},
146
+ history: [],
147
+ pauseReason: null,
148
+ };
149
+ (0, store_1.saveFlowState)(folderPath, state);
150
+ const run = {
151
+ projectId,
152
+ folderPath,
153
+ flowDef,
154
+ state,
155
+ watcher: null,
156
+ watcherDebounce: null,
157
+ timeoutTimer: null,
158
+ waitResolve: null,
159
+ userInputResolve: null,
160
+ userInputReject: null,
161
+ currentTaskIndex: null,
162
+ pendingLlmError: null,
163
+ };
164
+ this.active.set(projectId, run);
165
+ this.emit('state', { projectId, state });
166
+ log.info({
167
+ projectId,
168
+ flowId: flowDef.id,
169
+ flowName: flowDef.name,
170
+ entryNodeId: flowDef.entryNodeId,
171
+ nodeCount: flowDef.nodes.length,
172
+ runId: state.runId,
173
+ }, 'flow start');
174
+ // Fire-and-forget; the loop persists state on each transition.
175
+ void this.runLoop(run).catch((err) => {
176
+ log.error({ projectId, err: err instanceof Error ? err.message : String(err) }, 'run loop crashed');
177
+ this.finalize(run, 'failed', err instanceof Error ? err.message : String(err));
178
+ });
179
+ return { ok: true, state };
180
+ }
181
+ /**
182
+ * Resume a paused run by re-executing the current node. Caller intent:
183
+ * - timeout → re-inject prompt, wait again
184
+ * - max-retries-exceeded → reset that node's loop counter so user gets
185
+ * a fresh allotment (otherwise resume would immediately hit the same
186
+ * cap and pause again)
187
+ * - file-read-error → user fixed the file; re-read succeeds and node
188
+ * proceeds normally
189
+ * - awaiting-user-input → wrong path; caller should use submitUserInput
190
+ */
191
+ resume(projectId) {
192
+ const run = this.active.get(projectId);
193
+ if (!run)
194
+ return false;
195
+ if (run.state.status !== 'paused')
196
+ return false;
197
+ if (run.state.pauseReason === 'awaiting-user-input')
198
+ return false;
199
+ if (run.state.currentNodeId === null)
200
+ return false;
201
+ const prevReason = run.state.pauseReason;
202
+ if (run.state.pauseReason === 'max-retries-exceeded') {
203
+ delete run.state.loopCounters[run.state.currentNodeId];
204
+ }
205
+ run.state.status = 'running';
206
+ run.state.pauseReason = null;
207
+ run.state.pauseDetail = undefined;
208
+ this.persist(run);
209
+ log.info({ projectId, currentNodeId: run.state.currentNodeId, prevReason, resetLoopCounter: prevReason === 'max-retries-exceeded' }, 'flow resume');
210
+ void this.runLoop(run).catch((err) => {
211
+ log.error({ projectId: run.projectId, err: err instanceof Error ? err.message : String(err) }, 'resumed run loop crashed');
212
+ this.finalize(run, 'failed', err instanceof Error ? err.message : String(err));
213
+ });
214
+ return true;
215
+ }
216
+ abort(projectId) {
217
+ const run = this.active.get(projectId);
218
+ if (!run)
219
+ return false;
220
+ // Capture resolvers BEFORE clearWaiters — clearWaiters nulls
221
+ // run.waitResolve, so reading it after is a no-op and the in-flight
222
+ // wait Promise hangs forever (codex review P0).
223
+ const wait = run.waitResolve;
224
+ const userReject = run.userInputReject;
225
+ const hadUserInputWait = !!userReject;
226
+ const hadTaskWait = !!wait;
227
+ run.userInputResolve = null;
228
+ run.userInputReject = null;
229
+ // settle() inside waitForTaskFinish calls clearWaiters internally, so
230
+ // we don't need to call it here for the wait path. For the user-input
231
+ // path we still need finalize to clean up (no watcher/timer there).
232
+ wait?.('aborted');
233
+ userReject?.('aborted');
234
+ log.info({ projectId, currentNodeId: run.state.currentNodeId, hadTaskWait, hadUserInputWait }, 'flow abort');
235
+ this.finalize(run, 'aborted');
236
+ return true;
237
+ }
238
+ /** Resolves any pending user-input wait with the submitted form data. */
239
+ submitUserInput(projectId, data) {
240
+ const run = this.active.get(projectId);
241
+ if (!run || !run.userInputResolve)
242
+ return false;
243
+ const resolve = run.userInputResolve;
244
+ run.userInputResolve = null;
245
+ run.userInputReject = null;
246
+ // Log keys + value lengths only — field values may contain user research
247
+ // goals / unpublished hypotheses that we don't want in plaintext logs.
248
+ log.info({
249
+ projectId,
250
+ currentNodeId: run.state.currentNodeId,
251
+ fieldKeys: Object.keys(data),
252
+ fieldLengths: Object.fromEntries(Object.entries(data).map(([k, v]) => [k, v.length])),
253
+ }, 'flow user-input submitted');
254
+ resolve(data);
255
+ return true;
256
+ }
257
+ getState(projectId) {
258
+ return this.active.get(projectId)?.state ?? (0, store_1.loadFlowState)(this.lastFolderPath(projectId) ?? '');
259
+ }
260
+ isRunning(projectId) {
261
+ return this.active.has(projectId);
262
+ }
263
+ lastFolderPath(projectId) {
264
+ return this.active.get(projectId)?.folderPath ?? null;
265
+ }
266
+ // ── Main loop ─────────────────────────────────────────────────────────
267
+ async runLoop(run) {
268
+ while (run.state.status === 'running' && run.state.currentNodeId !== null) {
269
+ const node = run.flowDef.nodes.find((n) => n.id === run.state.currentNodeId);
270
+ if (!node) {
271
+ this.finalize(run, 'failed', `node id ${run.state.currentNodeId} not found`);
272
+ return;
273
+ }
274
+ const histEntry = {
275
+ nodeId: node.id,
276
+ startedAt: Date.now(),
277
+ finishedAt: null,
278
+ outcome: 'ok',
279
+ };
280
+ run.state.history.push(histEntry);
281
+ this.persist(run);
282
+ const outcome = await this.executeNode(run, node);
283
+ histEntry.finishedAt = Date.now();
284
+ histEntry.outcome = outcome.kind === 'ok' ? 'ok'
285
+ : outcome.kind === 'pause' ? 'pause'
286
+ : outcome.kind === 'retry' ? 'retry'
287
+ : 'error';
288
+ if (outcome.kind === 'pause' || outcome.kind === 'error') {
289
+ // executeNode already set status/pauseReason; persist + bail out.
290
+ this.persist(run);
291
+ return;
292
+ }
293
+ if (outcome.kind === 'ok') {
294
+ run.state.currentNodeId = outcome.next;
295
+ this.persist(run);
296
+ if (outcome.next === null) {
297
+ this.finalize(run, 'completed');
298
+ return;
299
+ }
300
+ }
301
+ // 'retry' just persists; loop continues with same state.currentNodeId
302
+ }
303
+ }
304
+ // ── Node executors ────────────────────────────────────────────────────
305
+ async executeNode(run, node) {
306
+ log.info({ projectId: run.projectId, nodeId: node.id, kind: node.kind, name: node.name }, 'executing node');
307
+ this.emit('state', { projectId: run.projectId, state: run.state });
308
+ if (node.kind === 'user-input')
309
+ return this.executeUserInput(run, node);
310
+ if (node.kind === 'llm')
311
+ return this.executeLlm(run, node);
312
+ if (node.kind === 'system-logic')
313
+ return this.executeSystemLogic(run, node);
314
+ return { kind: 'error', message: `unknown node kind: ${node.kind}` };
315
+ }
316
+ async executeUserInput(run, node) {
317
+ run.state.status = 'paused';
318
+ run.state.pauseReason = 'awaiting-user-input';
319
+ run.state.pendingUserInput = { nodeId: node.id, fields: node.userInputSchema.fields };
320
+ this.persist(run);
321
+ this.emit('user-input', { projectId: run.projectId, nodeId: node.id, fields: node.userInputSchema.fields });
322
+ log.info({
323
+ projectId: run.projectId,
324
+ nodeId: node.id,
325
+ fieldKeys: node.userInputSchema.fields.map((f) => f.key),
326
+ outputCount: node.outputs.length,
327
+ }, 'flow node user-input awaiting');
328
+ let data;
329
+ try {
330
+ data = await new Promise((resolve, reject) => {
331
+ run.userInputResolve = resolve;
332
+ run.userInputReject = reject;
333
+ });
334
+ }
335
+ catch (err) {
336
+ // Reject via abort() — outer handler does finalize.
337
+ return { kind: 'pause' };
338
+ }
339
+ // Write outputs: synthesize a JSON object from user fields and write to
340
+ // each declared output file. For Phase 1, multi-output user-input nodes
341
+ // get the same payload written to each — keeps the schema simple.
342
+ const payload = {};
343
+ for (const field of node.userInputSchema.fields)
344
+ payload[field.key] = data[field.key] ?? '';
345
+ for (const out of node.outputs) {
346
+ const abs = (0, store_1.safeProjectPath)(run.folderPath, out.path);
347
+ if (!abs) {
348
+ run.state.status = 'failed';
349
+ run.state.pauseReason = null;
350
+ run.state.pauseDetail = `unsafe output path rejected: ${out.path}`;
351
+ return { kind: 'error', message: run.state.pauseDetail };
352
+ }
353
+ try {
354
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
355
+ fs.writeFileSync(abs, JSON.stringify(payload, null, 2));
356
+ }
357
+ catch (err) {
358
+ run.state.status = 'failed';
359
+ run.state.pauseReason = null;
360
+ run.state.pauseDetail = `failed to write ${out.path}: ${err instanceof Error ? err.message : 'unknown'}`;
361
+ return { kind: 'error', message: run.state.pauseDetail };
362
+ }
363
+ }
364
+ run.state.status = 'running';
365
+ run.state.pauseReason = null;
366
+ run.state.pendingUserInput = undefined;
367
+ this.persist(run);
368
+ log.info({
369
+ projectId: run.projectId,
370
+ nodeId: node.id,
371
+ outputsWritten: node.outputs.map((o) => o.path),
372
+ next: node.next,
373
+ }, 'flow node user-input completed');
374
+ return { kind: 'ok', next: node.next };
375
+ }
376
+ async executeLlm(run, node) {
377
+ if (!this.injector) {
378
+ return { kind: 'error', message: 'no prompt injector configured' };
379
+ }
380
+ // 1. Read & validate inputs (provider-aware error routing)
381
+ const readResult = readInputs(run.folderPath, node.inputs);
382
+ if (!readResult.ok) {
383
+ log.warn({
384
+ projectId: run.projectId,
385
+ nodeId: node.id,
386
+ failingProvider: readResult.failingProvider,
387
+ error: readResult.error,
388
+ inputs: node.inputs.map((i) => i.path),
389
+ }, 'flow node llm input read failed');
390
+ if (readResult.failingProvider === 'user') {
391
+ run.state.status = 'paused';
392
+ run.state.pauseReason = 'user-file-read-error';
393
+ run.state.pauseDetail = `failed to read input file (provider=user): ${readResult.error}`;
394
+ this.emit('error', {
395
+ projectId: run.projectId,
396
+ nodeId: node.id,
397
+ reason: 'user-file-read-error',
398
+ detail: run.state.pauseDetail,
399
+ });
400
+ return { kind: 'pause' };
401
+ }
402
+ // provider=llm or system — stash error to inject in the next prompt to
403
+ // the LLM. Since the input was supposedly produced by an upstream LLM
404
+ // node, we still send the prompt to *this* node's LLM but with an
405
+ // explanatory wrapper, asking it to handle/repair.
406
+ run.pendingLlmError = {
407
+ path: node.inputs.find((i) => i.provider !== 'user')?.path ?? '?',
408
+ error: readResult.error ?? 'unknown',
409
+ };
410
+ }
411
+ // 2. task_todo entry
412
+ const taskIndex = (0, store_1.appendTaskTodo)(run.folderPath, {
413
+ id: node.id,
414
+ name: node.name,
415
+ finish: false,
416
+ });
417
+ run.currentTaskIndex = taskIndex;
418
+ // 3. Build prompt
419
+ const errorBlock = run.pendingLlmError
420
+ ? `\n\n[文件读取错误] 上游产物 ${run.pendingLlmError.path} 解析失败:${run.pendingLlmError.error}\n请先修复该文件再继续本任务。\n`
421
+ : '';
422
+ run.pendingLlmError = null;
423
+ const taskHeader = `当前任务 id=${node.id},名为「${node.name}」。\n` +
424
+ `完成后请把 .ccweb/task_todo.json 中索引 ${taskIndex} 处 entry 的 finish 字段改为 true(用 Edit/Write 工具直接更新该 JSON 文件)。\n` +
425
+ `\n──────── 任务正文 ────────\n`;
426
+ const body = renderTemplate(run.folderPath, node.promptTemplate);
427
+ const fullPrompt = `${taskHeader}${body}${errorBlock}`;
428
+ // 4. Inject into chat
429
+ this.injector(run.projectId, buildPaste(fullPrompt));
430
+ log.info({
431
+ projectId: run.projectId,
432
+ nodeId: node.id,
433
+ taskIndex,
434
+ promptSize: fullPrompt.length,
435
+ hasErrorBlock: errorBlock.length > 0,
436
+ timeoutSec: node.timeoutSec,
437
+ inputs: node.inputs.map((i) => i.path),
438
+ }, 'flow node llm prompt injected');
439
+ // 5. Wait for task_todo finish:true OR timeout
440
+ const outcome = await this.waitForTaskFinish(run, taskIndex, node.timeoutSec * 1000);
441
+ log.info({ projectId: run.projectId, nodeId: node.id, taskIndex, outcome }, 'flow node llm wait outcome');
442
+ if (outcome === 'aborted' || outcome === 'paused') {
443
+ return { kind: 'pause' };
444
+ }
445
+ if (outcome === 'timeout') {
446
+ run.state.status = 'paused';
447
+ run.state.pauseReason = 'timeout';
448
+ run.state.pauseDetail = `node ${node.id} (${node.name}) timed out after ${node.timeoutSec}s`;
449
+ this.emit('error', {
450
+ projectId: run.projectId,
451
+ nodeId: node.id,
452
+ reason: 'timeout',
453
+ detail: run.state.pauseDetail,
454
+ });
455
+ log.warn({ projectId: run.projectId, nodeId: node.id, timeoutSec: node.timeoutSec, taskIndex }, 'flow node llm timeout');
456
+ return { kind: 'pause' };
457
+ }
458
+ // finished — clear stale per-task fields
459
+ run.currentTaskIndex = null;
460
+ return { kind: 'ok', next: node.next };
461
+ }
462
+ async executeSystemLogic(run, node) {
463
+ // Read first input file as JSON
464
+ const inp = node.inputs[0];
465
+ if (!inp)
466
+ return { kind: 'error', message: `system-logic node ${node.id} has no inputs` };
467
+ const abs = (0, store_1.safeProjectPath)(run.folderPath, inp.path);
468
+ if (!abs) {
469
+ run.state.status = 'paused';
470
+ run.state.pauseReason = 'user-file-read-error';
471
+ run.state.pauseDetail = `unsafe input path rejected: ${inp.path}`;
472
+ return { kind: 'pause' };
473
+ }
474
+ let parsed;
475
+ try {
476
+ parsed = JSON.parse(fs.readFileSync(abs, 'utf-8'));
477
+ }
478
+ catch (err) {
479
+ const msg = err instanceof Error ? err.message : String(err);
480
+ log.warn({ projectId: run.projectId, nodeId: node.id, path: inp.path, provider: inp.provider, error: msg }, 'flow node system-logic input parse failed');
481
+ run.state.status = 'paused';
482
+ run.state.pauseReason = inp.provider === 'user' ? 'user-file-read-error' : 'llm-file-read-error';
483
+ run.state.pauseDetail = `failed to parse ${inp.path} (provider=${inp.provider}): ${msg}`;
484
+ if (inp.provider !== 'user') {
485
+ // Stash for any subsequent LLM node prompt to consume.
486
+ run.pendingLlmError = { path: inp.path, error: msg };
487
+ }
488
+ this.emit('error', {
489
+ projectId: run.projectId,
490
+ nodeId: node.id,
491
+ reason: run.state.pauseReason,
492
+ detail: run.state.pauseDetail,
493
+ });
494
+ return { kind: 'pause' };
495
+ }
496
+ // Evaluate branches with loose comparison — LLM often writes JSON
497
+ // booleans as strings ("true"/"false") or numbers; branch authors
498
+ // probably configured the typed primitive.
499
+ const obj = (parsed && typeof parsed === 'object') ? parsed : {};
500
+ let matched = null;
501
+ for (const rule of node.branches) {
502
+ if (branchMatches(obj[rule.field], rule.equals)) {
503
+ matched = rule;
504
+ break;
505
+ }
506
+ }
507
+ const goto = matched ? matched.goto : (node.defaultGoto ?? null);
508
+ log.info({
509
+ projectId: run.projectId,
510
+ nodeId: node.id,
511
+ topLevelKeys: Object.keys(obj),
512
+ matchedField: matched?.field,
513
+ matchedEquals: matched ? JSON.stringify(matched.equals) : undefined,
514
+ actualValue: matched ? JSON.stringify(obj[matched.field]) : undefined,
515
+ goto,
516
+ viaDefault: !matched,
517
+ }, 'flow node system-logic branch evaluated');
518
+ if (goto === null)
519
+ return { kind: 'ok', next: null };
520
+ // Backward edge detection by history (codex review P1d) — node ids may
521
+ // not be topologically ordered, so `goto < node.id` is unsafe. Visiting
522
+ // the same id twice in this run = loop edge.
523
+ const visited = new Set(run.state.history.map((h) => h.nodeId));
524
+ const isBackward = visited.has(goto);
525
+ if (isBackward) {
526
+ const count = (run.state.loopCounters[node.id] ?? 0) + 1;
527
+ run.state.loopCounters[node.id] = count;
528
+ log.info({ projectId: run.projectId, nodeId: node.id, goto, loopCount: count, maxRetries: node.maxRetries }, 'flow node system-logic loop edge');
529
+ if (count > node.maxRetries) {
530
+ run.state.status = 'paused';
531
+ run.state.pauseReason = 'max-retries-exceeded';
532
+ run.state.pauseDetail = `node ${node.id} backward edge to ${goto} exceeded maxRetries=${node.maxRetries}`;
533
+ this.emit('error', {
534
+ projectId: run.projectId,
535
+ nodeId: node.id,
536
+ reason: 'max-retries-exceeded',
537
+ detail: run.state.pauseDetail,
538
+ });
539
+ log.warn({ projectId: run.projectId, nodeId: node.id, goto, maxRetries: node.maxRetries }, 'flow node system-logic max-retries exceeded');
540
+ return { kind: 'pause' };
541
+ }
542
+ }
543
+ return { kind: 'ok', next: goto };
544
+ }
545
+ // ── Wait helpers ──────────────────────────────────────────────────────
546
+ waitForTaskFinish(run, taskIndex, timeoutMs) {
547
+ return new Promise((resolve) => {
548
+ let settled = false;
549
+ const settle = (v) => {
550
+ if (settled)
551
+ return;
552
+ settled = true;
553
+ this.clearWaiters(run);
554
+ resolve(v);
555
+ };
556
+ run.waitResolve = settle;
557
+ // Initial check — finish may have raced ahead before we attached.
558
+ try {
559
+ const todo = (0, store_1.readTaskTodo)(run.folderPath);
560
+ if (todo.tasks[taskIndex]?.finish === true) {
561
+ log.info({ projectId: run.projectId, taskIndex, via: 'initial-check' }, 'flow task_todo finish detected');
562
+ settle('finished');
563
+ return;
564
+ }
565
+ }
566
+ catch { /* ignore */ }
567
+ const filePath = (0, store_1.taskTodoPath)(run.folderPath);
568
+ try {
569
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
570
+ }
571
+ catch { /* ignore */ }
572
+ try {
573
+ run.watcher = fs.watch(filePath, { persistent: false }, () => {
574
+ if (settled)
575
+ return;
576
+ if (run.watcherDebounce)
577
+ clearTimeout(run.watcherDebounce);
578
+ run.watcherDebounce = setTimeout(() => {
579
+ run.watcherDebounce = null;
580
+ if (settled)
581
+ return;
582
+ try {
583
+ const todo = (0, store_1.readTaskTodo)(run.folderPath);
584
+ if (todo.tasks[taskIndex]?.finish === true) {
585
+ log.info({ projectId: run.projectId, taskIndex, via: 'watcher' }, 'flow task_todo finish detected');
586
+ settle('finished');
587
+ }
588
+ }
589
+ catch { /* keep waiting */ }
590
+ }, 50);
591
+ });
592
+ }
593
+ catch (err) {
594
+ log.warn({ projectId: run.projectId, err: err instanceof Error ? err.message : String(err) }, 'task_todo watch failed — falling back to polling');
595
+ // Fallback: 500ms poll
596
+ const poll = setInterval(() => {
597
+ try {
598
+ const todo = (0, store_1.readTaskTodo)(run.folderPath);
599
+ if (todo.tasks[taskIndex]?.finish === true) {
600
+ clearInterval(poll);
601
+ settle('finished');
602
+ }
603
+ }
604
+ catch { /* ignore */ }
605
+ }, 500);
606
+ // Tie poll to settle: we can't directly cancel here, but settle's
607
+ // clearWaiters won't reach it. Wrap by mirroring as a fake watcher
608
+ // via run.timeoutTimer ergonomics — simpler: stash on run object.
609
+ // For phase-1 acceptable, just accept the small leak past timeout.
610
+ run._poll = poll;
611
+ }
612
+ run.timeoutTimer = setTimeout(() => settle('timeout'), timeoutMs);
613
+ });
614
+ }
615
+ clearWaiters(run) {
616
+ if (run.watcher) {
617
+ try {
618
+ run.watcher.close();
619
+ }
620
+ catch { /* ignore */ }
621
+ run.watcher = null;
622
+ }
623
+ if (run.watcherDebounce) {
624
+ clearTimeout(run.watcherDebounce);
625
+ run.watcherDebounce = null;
626
+ }
627
+ if (run.timeoutTimer) {
628
+ clearTimeout(run.timeoutTimer);
629
+ run.timeoutTimer = null;
630
+ }
631
+ const r = run;
632
+ if (r._poll) {
633
+ clearInterval(r._poll);
634
+ r._poll = undefined;
635
+ }
636
+ run.waitResolve = null;
637
+ }
638
+ persist(run) {
639
+ (0, store_1.saveFlowState)(run.folderPath, run.state);
640
+ this.emit('state', { projectId: run.projectId, state: run.state });
641
+ }
642
+ finalize(run, status, detail) {
643
+ run.state.status = status;
644
+ run.state.currentNodeId = null;
645
+ if (detail)
646
+ run.state.pauseDetail = detail;
647
+ this.clearWaiters(run);
648
+ (0, store_1.saveFlowState)(run.folderPath, run.state);
649
+ this.active.delete(run.projectId);
650
+ this.emit('state', { projectId: run.projectId, state: run.state });
651
+ log.info({ projectId: run.projectId, status, detail }, 'flow finalized');
652
+ }
653
+ }
654
+ exports.FlowRunner = FlowRunner;
655
+ exports.flowRunner = new FlowRunner();
656
+ //# sourceMappingURL=runner.js.map