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