@xiaohhhh1/canvas-agent 0.4.74 → 0.4.76
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/dist/agent/codex-client.d.ts +53 -2
- package/dist/agent/codex-client.js +299 -60
- package/dist/agent/codex.d.ts +25 -0
- package/dist/agent/codex.js +179 -29
- package/dist/utils/logger.d.ts +3 -0
- package/dist/utils/logger.js +18 -0
- package/dist/workflow/commerce-http.d.ts +34 -0
- package/dist/workflow/commerce-http.js +114 -0
- package/dist/workflow/manager.d.ts +34 -15
- package/dist/workflow/manager.js +178 -81
- package/package.json +1 -1
package/dist/agent/codex.js
CHANGED
|
@@ -3,12 +3,14 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { logger } from "../utils/logger.js";
|
|
5
5
|
import { errorMessage, field } from "../utils/value.js";
|
|
6
|
-
import { CodexAppClient } from "./codex-client.js";
|
|
6
|
+
import { CodexAppClient, isCodexAppProcessError } from "./codex-client.js";
|
|
7
7
|
import { summarizeCodexThread, threadMessages } from "./codex-history.js";
|
|
8
8
|
import { boundedWorkerConcurrency, WorkerPool } from "./worker-pool.js";
|
|
9
9
|
export const FLOW_C_CODEX_MODEL = "gpt-5.6-terra";
|
|
10
10
|
export const FLOW_C_CODEX_REASONING_EFFORT = "medium";
|
|
11
11
|
export const FLOW_C_CODEX_WORKER_CONCURRENCY = boundedWorkerConcurrency(process.env.FLOW_C_SCRIPT_CONCURRENCY);
|
|
12
|
+
export const FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS = 3;
|
|
13
|
+
const FLOW_C_CODEX_MIN_START_BUDGET_MS = 5_000;
|
|
12
14
|
export function flowCCodexWorkerStatus() { return { active: workflowCodexPool.activeCount, limit: FLOW_C_CODEX_WORKER_CONCURRENCY }; }
|
|
13
15
|
let codexQueue = Promise.resolve();
|
|
14
16
|
let codexApp = null;
|
|
@@ -17,6 +19,46 @@ let codexThreadId = "";
|
|
|
17
19
|
const unmaterializedThreadIds = new Set();
|
|
18
20
|
const workflowCodexPool = new WorkerPool(FLOW_C_CODEX_WORKER_CONCURRENCY);
|
|
19
21
|
const workflowCodexApps = new Map();
|
|
22
|
+
/**
|
|
23
|
+
* 记录尚未确认 OS exit 的 worker 进程。worker 可以释放给队列,但在对应
|
|
24
|
+
* barrier 完成前只能等待/失败,绝不能启动替代 app-server。
|
|
25
|
+
*/
|
|
26
|
+
export class WorkflowCodexLaneQuarantine {
|
|
27
|
+
barriers = new Map();
|
|
28
|
+
quarantine(workerIndex, barrier) {
|
|
29
|
+
const previous = this.barriers.get(workerIndex);
|
|
30
|
+
const combined = (previous ? Promise.all([previous, barrier]) : Promise.resolve(barrier))
|
|
31
|
+
.then(() => undefined, () => undefined);
|
|
32
|
+
this.barriers.set(workerIndex, combined);
|
|
33
|
+
void combined.then(() => {
|
|
34
|
+
if (this.barriers.get(workerIndex) === combined)
|
|
35
|
+
this.barriers.delete(workerIndex);
|
|
36
|
+
});
|
|
37
|
+
return combined;
|
|
38
|
+
}
|
|
39
|
+
async wait(workerIndex, timeoutMs) {
|
|
40
|
+
const barrier = this.barriers.get(workerIndex);
|
|
41
|
+
if (!barrier)
|
|
42
|
+
return true;
|
|
43
|
+
if (timeoutMs <= 0)
|
|
44
|
+
return false;
|
|
45
|
+
let timer;
|
|
46
|
+
try {
|
|
47
|
+
return await Promise.race([
|
|
48
|
+
barrier.then(() => true),
|
|
49
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }),
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
if (timer)
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
isQuarantined(workerIndex) {
|
|
58
|
+
return this.barriers.has(workerIndex);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const workflowCodexLaneQuarantine = new WorkflowCodexLaneQuarantine();
|
|
20
62
|
export { summarizeCodexThread } from "./codex-history.js";
|
|
21
63
|
/** 将 Codex turn 加入串行队列并等待执行完成。 */
|
|
22
64
|
export async function runCodexTurn(prompt, emit, attachments = [], options = {}) {
|
|
@@ -37,41 +79,104 @@ export async function interruptCodexTurn(threadId) {
|
|
|
37
79
|
*/
|
|
38
80
|
export async function runCodexWorkflowTurn(prompt, emit, options) {
|
|
39
81
|
if (!prompt.trim())
|
|
40
|
-
return { ok: false, error: "Codex prompt is empty", retryable: false, timings: { queueWaitMs: 0, threadStartMs: 0, modelMs: 0 } };
|
|
82
|
+
return { ok: false, error: "Codex prompt is empty", retryable: false, failureKind: "contract", timings: { queueWaitMs: 0, threadStartMs: 0, modelMs: 0 } };
|
|
41
83
|
return await workflowCodexPool.run(async (workerIndex, queueWaitMs) => {
|
|
42
84
|
options.onWorkerStart?.();
|
|
43
85
|
const modelSettings = { model: FLOW_C_CODEX_MODEL, reasoningEffort: FLOW_C_CODEX_REASONING_EFFORT };
|
|
44
|
-
const
|
|
86
|
+
const deadlineAt = Date.now() + Math.max(1, options.timeoutMs);
|
|
45
87
|
let threadStartMs = 0;
|
|
46
|
-
let
|
|
47
|
-
let app
|
|
88
|
+
let modelMs = 0;
|
|
89
|
+
let app;
|
|
90
|
+
let processAttempts = 0;
|
|
91
|
+
let processRecoveryObserved = false;
|
|
92
|
+
let cleanupConfirmed = true;
|
|
48
93
|
let files = [];
|
|
49
94
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
95
|
+
files = await writeAttachmentFiles(options.attachments || []);
|
|
96
|
+
const text = await runCodexProcessRetries(async (attempt) => {
|
|
97
|
+
processAttempts = attempt;
|
|
98
|
+
const attemptStartedAt = Date.now();
|
|
99
|
+
let modelStartedAt = 0;
|
|
100
|
+
try {
|
|
101
|
+
const laneReady = await workflowCodexLaneQuarantine.wait(workerIndex, Math.max(0, deadlineAt - Date.now()));
|
|
102
|
+
if (!laneReady) {
|
|
103
|
+
cleanupConfirmed = false;
|
|
104
|
+
throw new CodexWorkflowTimeoutError();
|
|
105
|
+
}
|
|
106
|
+
const startupBudgetMs = deadlineAt - Date.now();
|
|
107
|
+
if (startupBudgetMs < FLOW_C_CODEX_MIN_START_BUDGET_MS)
|
|
108
|
+
throw new CodexWorkflowTimeoutError();
|
|
109
|
+
app = workflowCodexApps.get(workerIndex);
|
|
110
|
+
if (!app)
|
|
111
|
+
app = await startWorkflowCodexApp(workerIndex, options.appEmit || emit, startupBudgetMs);
|
|
112
|
+
const remainingMs = deadlineAt - Date.now();
|
|
113
|
+
if (remainingMs <= 0)
|
|
114
|
+
throw new CodexWorkflowTimeoutError();
|
|
115
|
+
const operation = (async () => {
|
|
116
|
+
const thread = await app.startThread(options.cwd, options.permissionMode || "request", modelSettings);
|
|
117
|
+
const threadId = String(field(thread, "id") || "");
|
|
118
|
+
options.onThread?.(threadId);
|
|
119
|
+
modelStartedAt = Date.now();
|
|
120
|
+
return await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn, options.outputSchema, modelSettings);
|
|
121
|
+
})();
|
|
122
|
+
const result = await runBoundedWorkflowOperation(operation, remainingMs, async () => {
|
|
123
|
+
const timedOutApp = app;
|
|
124
|
+
cleanupConfirmed = await discardWorkflowCodexApp(workerIndex, timedOutApp, "Flow C 脚本执行链路超时,已仅回收当前 worker", true);
|
|
125
|
+
if (app === timedOutApp)
|
|
126
|
+
app = undefined;
|
|
127
|
+
});
|
|
128
|
+
if (result.timedOut)
|
|
129
|
+
throw new CodexWorkflowTimeoutError();
|
|
130
|
+
return result.value;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
if (modelStartedAt)
|
|
134
|
+
modelMs += Date.now() - modelStartedAt;
|
|
135
|
+
else
|
|
136
|
+
threadStartMs += Date.now() - attemptStartedAt;
|
|
137
|
+
}
|
|
138
|
+
}, {
|
|
139
|
+
maxAttempts: FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS,
|
|
140
|
+
backoffMs: (failedAttempt) => Math.max(0, Math.min(failedAttempt === 1 ? 250 : 750, deadlineAt - Date.now())),
|
|
141
|
+
onRetry: async (error, failedAttempt) => {
|
|
142
|
+
processRecoveryObserved = true;
|
|
143
|
+
cleanupConfirmed = await discardWorkflowCodexApp(workerIndex, app, `Flow C 本机脚本引擎第 ${failedAttempt}/${FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS} 次进程异常,正在换新进程重试`);
|
|
144
|
+
app = undefined;
|
|
145
|
+
if (!cleanupConfirmed)
|
|
146
|
+
throw error;
|
|
147
|
+
if (deadlineAt - Date.now() < FLOW_C_CODEX_MIN_START_BUDGET_MS)
|
|
148
|
+
throw new CodexWorkflowTimeoutError();
|
|
149
|
+
emit("agent_log", { text: `Flow C 本机脚本引擎异常,已回收当前 worker,将进行第 ${failedAttempt + 1}/${FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS} 次尝试(${error.message})` });
|
|
150
|
+
},
|
|
64
151
|
});
|
|
65
|
-
|
|
66
|
-
return { ok: false, error: "Flow C 脚本执行链路超过 8 分钟,已自动终止当前 worker", retryable: true, timings: { queueWaitMs, threadStartMs: threadStartMs || Date.now() - threadStartedAt, modelMs: modelStartedAt ? Date.now() - modelStartedAt : 0 } };
|
|
67
|
-
}
|
|
68
|
-
return { ok: true, text: result.value, timings: { queueWaitMs, threadStartMs, modelMs: Date.now() - modelStartedAt } };
|
|
152
|
+
return { ok: true, text, timings: { queueWaitMs, threadStartMs, modelMs } };
|
|
69
153
|
}
|
|
70
154
|
catch (error) {
|
|
71
155
|
logger.error("Flow C Codex worker failed", { workerIndex, error });
|
|
72
|
-
const
|
|
156
|
+
const processFailure = isCodexAppProcessError(error);
|
|
157
|
+
const contractFailure = isDeterministicWorkflowContractError(error);
|
|
158
|
+
const timeoutFailure = error instanceof CodexWorkflowTimeoutError;
|
|
159
|
+
const deadlineAfterProcessFailure = timeoutFailure && processRecoveryObserved;
|
|
160
|
+
const transportFailure = !contractFailure && (processFailure || deadlineAfterProcessFailure);
|
|
161
|
+
if (processFailure && error.cleanupConfirmed === false)
|
|
162
|
+
cleanupConfirmed = false;
|
|
163
|
+
if (processFailure && error.cleanupBarrier)
|
|
164
|
+
workflowCodexLaneQuarantine.quarantine(workerIndex, error.cleanupBarrier);
|
|
165
|
+
const failureKind = contractFailure ? "contract" : transportFailure ? "transport" : timeoutFailure ? "timeout" : "turn";
|
|
166
|
+
const rawMessage = errorMessage(error);
|
|
167
|
+
let message = deadlineAfterProcessFailure
|
|
168
|
+
? `本机 Codex 脚本引擎进程异常后未能在原 8 分钟截止时间内完成恢复,已停止当前任务:${rawMessage}`
|
|
169
|
+
: transportFailure
|
|
170
|
+
? `本机 Codex 脚本引擎已自动尝试 ${Math.max(1, processAttempts)}/${FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS} 次仍失败:${rawMessage}`
|
|
171
|
+
: timeoutFailure ? "Flow C 脚本执行链路超过 8 分钟,已自动终止当前 worker" : rawMessage;
|
|
172
|
+
if ((processFailure || timeoutFailure) && app) {
|
|
173
|
+
cleanupConfirmed = await discardWorkflowCodexApp(workerIndex, app, "Flow C 脚本执行结束,正在确认当前 worker 已退出", timeoutFailure);
|
|
174
|
+
app = undefined;
|
|
175
|
+
}
|
|
176
|
+
if (!cleanupConfirmed)
|
|
177
|
+
message = `${message};旧 app-server 在 2 秒清理宽限内未确认退出,已停止自动启动替代进程`;
|
|
73
178
|
emit("agent_error", { message });
|
|
74
|
-
return { ok: false, error: message, retryable:
|
|
179
|
+
return { ok: false, error: message, retryable: isWorkflowFailureRetryable(failureKind, cleanupConfirmed, processRecoveryObserved), failureKind, timings: { queueWaitMs, threadStartMs, modelMs } };
|
|
75
180
|
}
|
|
76
181
|
finally {
|
|
77
182
|
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
|
@@ -79,6 +184,38 @@ export async function runCodexWorkflowTurn(prompt, emit, options) {
|
|
|
79
184
|
}
|
|
80
185
|
});
|
|
81
186
|
}
|
|
187
|
+
/** A deadline exhausted after process recovery is terminal for this handoff wave. */
|
|
188
|
+
export function isWorkflowFailureRetryable(failureKind, cleanupConfirmed, processRecoveryObserved) {
|
|
189
|
+
if (!cleanupConfirmed)
|
|
190
|
+
return false;
|
|
191
|
+
if (failureKind === "turn")
|
|
192
|
+
return true;
|
|
193
|
+
return failureKind === "timeout" && !processRecoveryObserved;
|
|
194
|
+
}
|
|
195
|
+
/** 只重试明确的 app-server 进程/stdio 故障;结构契约和普通模型失败均立即上抛。 */
|
|
196
|
+
export async function runCodexProcessRetries(operation, options = {}) {
|
|
197
|
+
const maxAttempts = Math.max(1, Math.floor(options.maxAttempts || FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS));
|
|
198
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
199
|
+
try {
|
|
200
|
+
return await operation(attempt);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (isDeterministicWorkflowContractError(error) || !isCodexAppProcessError(error) || error.cleanupConfirmed === false || attempt >= maxAttempts)
|
|
204
|
+
throw error;
|
|
205
|
+
await options.onRetry?.(error, attempt);
|
|
206
|
+
const backoffMs = typeof options.backoffMs === "function" ? options.backoffMs(attempt) : Number(options.backoffMs || 0);
|
|
207
|
+
if (backoffMs > 0)
|
|
208
|
+
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
throw new Error("Codex app-server process retry loop ended unexpectedly");
|
|
212
|
+
}
|
|
213
|
+
class CodexWorkflowTimeoutError extends Error {
|
|
214
|
+
constructor() {
|
|
215
|
+
super("Flow C script workflow deadline exceeded");
|
|
216
|
+
this.name = "CodexWorkflowTimeoutError";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
82
219
|
/**
|
|
83
220
|
* 给 Flow C 的完整 app-server 链路设置硬截止时间。超时后不再等待原 Promise
|
|
84
221
|
* 自行结束;否则初始化或 thread/start 永不返回时会永久占住 worker lane。
|
|
@@ -107,18 +244,31 @@ export async function runBoundedWorkflowOperation(operation, timeoutMs, onTimeou
|
|
|
107
244
|
}
|
|
108
245
|
/** Contract/preflight 4xx errors are deterministic and must never burn every fallback chunk size. */
|
|
109
246
|
export function isDeterministicWorkflowContractError(error) {
|
|
110
|
-
const message = errorMessage(error)
|
|
247
|
+
const message = `${errorMessage(error)}\n${isCodexAppProcessError(error) ? error.stderr : ""}`;
|
|
111
248
|
return /invalid_json_schema|invalid schema for response_format|text\.format\.schema|response[_ ]format[^\n]*(?:invalid|schema)/i.test(message);
|
|
112
249
|
}
|
|
113
|
-
async function startWorkflowCodexApp(workerIndex, emit) {
|
|
250
|
+
async function startWorkflowCodexApp(workerIndex, emit, initializeTimeoutMs) {
|
|
114
251
|
let started;
|
|
115
252
|
started = await CodexAppClient.start(emit, () => {
|
|
116
|
-
if (!started
|
|
253
|
+
if (!started)
|
|
254
|
+
return;
|
|
255
|
+
workflowCodexLaneQuarantine.quarantine(workerIndex, started.waitForExit());
|
|
256
|
+
if (workflowCodexApps.get(workerIndex) === started)
|
|
117
257
|
workflowCodexApps.delete(workerIndex);
|
|
118
|
-
});
|
|
258
|
+
}, initializeTimeoutMs);
|
|
119
259
|
workflowCodexApps.set(workerIndex, started);
|
|
120
260
|
return started;
|
|
121
261
|
}
|
|
262
|
+
async function discardWorkflowCodexApp(workerIndex, app, message, persistDiagnostic = false) {
|
|
263
|
+
if (app && workflowCodexApps.get(workerIndex) === app)
|
|
264
|
+
workflowCodexApps.delete(workerIndex);
|
|
265
|
+
if (!app)
|
|
266
|
+
return true;
|
|
267
|
+
const confirmed = await app.terminate(message, { persistDiagnostic });
|
|
268
|
+
if (!confirmed)
|
|
269
|
+
workflowCodexLaneQuarantine.quarantine(workerIndex, app.waitForExit());
|
|
270
|
+
return confirmed;
|
|
271
|
+
}
|
|
122
272
|
/** 强制回收失去响应的 app-server;只用于有界超时恢复。 */
|
|
123
273
|
export async function restartCodexApp(message = "Codex 执行超时,正在重启本机脚本引擎") {
|
|
124
274
|
const app = codexApp;
|
package/dist/utils/logger.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export declare class Logger {
|
|
|
3
3
|
readonly enabled: boolean;
|
|
4
4
|
readonly filePath: string;
|
|
5
5
|
private readonly logger;
|
|
6
|
+
private failureLogger?;
|
|
6
7
|
/** 根据命令行 Debug 参数初始化日志输出。 */
|
|
7
8
|
constructor();
|
|
8
9
|
/** 输出 Debug 级别日志。 */
|
|
@@ -13,5 +14,7 @@ export declare class Logger {
|
|
|
13
14
|
warn(message: string, details?: unknown): void;
|
|
14
15
|
/** 输出 Error 级别日志。 */
|
|
15
16
|
error(message: string, details?: unknown): void;
|
|
17
|
+
/** Always persist minimal, caller-whitelisted failure metadata; no request bodies or URLs. */
|
|
18
|
+
failure(message: string, details: Record<string, unknown>): void;
|
|
16
19
|
}
|
|
17
20
|
export declare const logger: Logger;
|
package/dist/utils/logger.js
CHANGED
|
@@ -9,6 +9,7 @@ export class Logger {
|
|
|
9
9
|
enabled = process.argv.includes("--debug");
|
|
10
10
|
filePath = this.enabled ? path.join(os.homedir(), ".infinite-canvas", "logs", `canvas-agent-${formatDateForFilename()}.log`) : "";
|
|
11
11
|
logger;
|
|
12
|
+
failureLogger;
|
|
12
13
|
/** 根据命令行 Debug 参数初始化日志输出。 */
|
|
13
14
|
constructor() {
|
|
14
15
|
if (!this.enabled) {
|
|
@@ -53,6 +54,23 @@ export class Logger {
|
|
|
53
54
|
else
|
|
54
55
|
this.logger?.error(message, { details: sanitize(details) });
|
|
55
56
|
}
|
|
57
|
+
/** Always persist minimal, caller-whitelisted failure metadata; no request bodies or URLs. */
|
|
58
|
+
failure(message, details) {
|
|
59
|
+
try {
|
|
60
|
+
if (!this.failureLogger) {
|
|
61
|
+
const directory = path.join(os.homedir(), ".infinite-canvas", "logs");
|
|
62
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
63
|
+
this.failureLogger = winston.createLogger({
|
|
64
|
+
level: "warn",
|
|
65
|
+
format: format.combine(format.timestamp(), format.json()),
|
|
66
|
+
transports: [new transports.File({ filename: path.join(directory, "canvas-agent-failures.log"), maxsize: 1_000_000, maxFiles: 3, options: { flags: "a", mode: 0o600 } })],
|
|
67
|
+
});
|
|
68
|
+
this.failureLogger.on("error", () => { });
|
|
69
|
+
}
|
|
70
|
+
this.failureLogger.warn(message, sanitize(details));
|
|
71
|
+
}
|
|
72
|
+
catch { /* Failure logging must never lose a saved result or mask the request error. */ }
|
|
73
|
+
}
|
|
56
74
|
}
|
|
57
75
|
/** 将日志详情格式化为紧凑的单行文本。 */
|
|
58
76
|
function formatDetails(details) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type CommerceRequestDiagnostic = {
|
|
2
|
+
stage: string;
|
|
3
|
+
method: string;
|
|
4
|
+
requestId: string;
|
|
5
|
+
name: string;
|
|
6
|
+
cause?: {
|
|
7
|
+
code: string;
|
|
8
|
+
};
|
|
9
|
+
status?: number;
|
|
10
|
+
attempt: number;
|
|
11
|
+
elapsedMs: number;
|
|
12
|
+
at: string;
|
|
13
|
+
};
|
|
14
|
+
export declare class CommerceRequestError extends Error {
|
|
15
|
+
readonly diagnostic: CommerceRequestDiagnostic;
|
|
16
|
+
readonly retryable: boolean;
|
|
17
|
+
status?: number;
|
|
18
|
+
code?: string;
|
|
19
|
+
resetOrdinals?: unknown;
|
|
20
|
+
rewriteOrdinals?: unknown;
|
|
21
|
+
constructor(message: string, diagnostic: CommerceRequestDiagnostic, retryable: boolean);
|
|
22
|
+
}
|
|
23
|
+
type RequestOptions = {
|
|
24
|
+
expiresAt?: string;
|
|
25
|
+
onFailure?: (diagnostic: CommerceRequestDiagnostic) => void;
|
|
26
|
+
};
|
|
27
|
+
type Transport = {
|
|
28
|
+
fetch: typeof fetch;
|
|
29
|
+
sleep: (ms: number) => Promise<void>;
|
|
30
|
+
now: () => number;
|
|
31
|
+
};
|
|
32
|
+
/** Only read-only requests and the two server-deduplicated handoff deliveries may retry. */
|
|
33
|
+
export declare function commerceJson<T>(url: string, token: string, tokenHeader: string, init?: RequestInit, options?: RequestOptions, transport?: Transport): Promise<T>;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
export class CommerceRequestError extends Error {
|
|
3
|
+
diagnostic;
|
|
4
|
+
retryable;
|
|
5
|
+
status;
|
|
6
|
+
code;
|
|
7
|
+
resetOrdinals;
|
|
8
|
+
rewriteOrdinals;
|
|
9
|
+
constructor(message, diagnostic, retryable) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.diagnostic = diagnostic;
|
|
12
|
+
this.retryable = retryable;
|
|
13
|
+
this.name = "CommerceRequestError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const defaultTransport = { fetch: (...args) => fetch(...args), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now: () => Date.now() };
|
|
17
|
+
const safeCode = (value) => typeof value === "string" && /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(value) ? value : undefined;
|
|
18
|
+
/** Only read-only requests and the two server-deduplicated handoff deliveries may retry. */
|
|
19
|
+
export async function commerceJson(url, token, tokenHeader, init = {}, options = {}, transport = defaultTransport) {
|
|
20
|
+
const method = String(init.method || "GET").toUpperCase();
|
|
21
|
+
const pathname = new URL(url).pathname;
|
|
22
|
+
const handoff = /^\/api\/commerce\/workflow-script-handoffs\/[0-9a-f-]{36}\/(task|product-profiles|draft-chunks)$/.exec(pathname);
|
|
23
|
+
const canRetry = Boolean(handoff && (method === "GET" && handoff[1] === "task" || method === "POST" && typeof init.body === "string" && ["product-profiles", "draft-chunks"].includes(handoff[1])));
|
|
24
|
+
const stage = handoff?.[1] || "commerce-request";
|
|
25
|
+
const requestId = randomUUID();
|
|
26
|
+
const headers = new Headers(init.headers);
|
|
27
|
+
headers.set("accept", "application/json");
|
|
28
|
+
headers.set(tokenHeader, token);
|
|
29
|
+
headers.set("x-request-id", requestId);
|
|
30
|
+
if (init.body)
|
|
31
|
+
headers.set("content-type", "application/json");
|
|
32
|
+
// Capture the exact serialized payload once; retries never rebuild model output.
|
|
33
|
+
const request = { ...init, method, headers };
|
|
34
|
+
const startedAt = transport.now();
|
|
35
|
+
const expiresAt = options.expiresAt === undefined ? Infinity : typeof options.expiresAt === "string" ? Date.parse(options.expiresAt) : NaN;
|
|
36
|
+
const invalidCapability = options.expiresAt !== undefined && !Number.isFinite(expiresAt);
|
|
37
|
+
for (let attempt = 1;; attempt += 1) {
|
|
38
|
+
let status;
|
|
39
|
+
let body = {};
|
|
40
|
+
let expired = false;
|
|
41
|
+
try {
|
|
42
|
+
if (invalidCapability)
|
|
43
|
+
throw new Error("InvalidCapability");
|
|
44
|
+
if (init.signal?.aborted)
|
|
45
|
+
throw new Error("AbortError");
|
|
46
|
+
const remaining = expiresAt - transport.now();
|
|
47
|
+
if (remaining <= 0) {
|
|
48
|
+
expired = true;
|
|
49
|
+
throw new Error("CapabilityExpired");
|
|
50
|
+
}
|
|
51
|
+
const response = await transport.fetch(url, { ...request, signal: AbortSignal.timeout(Math.max(1, Math.min(120_000, Number.isFinite(remaining) ? remaining : 120_000))) });
|
|
52
|
+
status = response.status;
|
|
53
|
+
// A lost/truncated successful response is ambiguous delivery, never an empty success.
|
|
54
|
+
try {
|
|
55
|
+
const value = await response.json();
|
|
56
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
57
|
+
throw new SyntaxError("InvalidResponse");
|
|
58
|
+
body = value;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (response.ok)
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
if (!response.ok)
|
|
65
|
+
throw new Error("HTTPError");
|
|
66
|
+
if (handoff)
|
|
67
|
+
validateHandoffResponse(stage, body, pathname.split("/").at(-2));
|
|
68
|
+
return body;
|
|
69
|
+
}
|
|
70
|
+
catch (cause) {
|
|
71
|
+
const aborted = Boolean(init.signal?.aborted);
|
|
72
|
+
const error = cause;
|
|
73
|
+
const causeCode = safeCode(error?.cause?.code) || safeCode(error?.code);
|
|
74
|
+
const diagnostic = {
|
|
75
|
+
stage, method, requestId, name: invalidCapability ? "InvalidCapability" : expired ? "CapabilityExpired" : aborted ? "AbortError" : status && status >= 400 ? "HTTPError" : safeCode(error?.name) || "Error",
|
|
76
|
+
...(causeCode ? { cause: { code: causeCode } } : {}), ...(status ? { status } : {}),
|
|
77
|
+
attempt, elapsedMs: Math.max(0, transport.now() - startedAt), at: new Date(transport.now()).toISOString(),
|
|
78
|
+
};
|
|
79
|
+
const contractFailure = /invalid_json_schema|invalid_response_format|invalid response_format/i.test(`${body.code || ""} ${body.error || ""}`);
|
|
80
|
+
const retryable = !invalidCapability && !expired && !aborted && !contractFailure && (status === undefined || status >= 200 && status < 300 || [408, 429, 500, 502, 503, 504].includes(status));
|
|
81
|
+
const message = invalidCapability ? "脚本交接有效期无效,请在网页重新授权交接"
|
|
82
|
+
: expired ? "脚本交接能力已过期,请在网页重新授权交接"
|
|
83
|
+
: aborted ? "中心请求已取消,已保留本机结果"
|
|
84
|
+
: status && status >= 400 ? String(body.error || `中心接口返回 ${status}`).split(token).join("[REDACTED]").replace(/https?:\/\/\S+/gi, "[URL]").slice(0, 500)
|
|
85
|
+
: `中心${stage === "task" ? "任务读取" : "结果回传"}连接失败,已保留本机结果,请重试(诊断 ${requestId})`;
|
|
86
|
+
const failure = new CommerceRequestError(message, diagnostic, retryable);
|
|
87
|
+
failure.status = status;
|
|
88
|
+
failure.code = invalidCapability ? "WORKFLOW_CAPABILITY_INVALID" : expired ? "WORKFLOW_CAPABILITY_EXPIRED" : aborted ? "WORKFLOW_REQUEST_ABORTED" : safeCode(body.code);
|
|
89
|
+
failure.resetOrdinals = body.resetOrdinals;
|
|
90
|
+
failure.rewriteOrdinals = body.rewriteOrdinals;
|
|
91
|
+
options.onFailure?.(diagnostic);
|
|
92
|
+
if (!canRetry || !retryable || attempt >= 3 || init.signal?.aborted)
|
|
93
|
+
throw failure;
|
|
94
|
+
const delay = attempt === 1 ? 500 : 1500;
|
|
95
|
+
if (transport.now() + delay >= expiresAt)
|
|
96
|
+
throw failure;
|
|
97
|
+
await transport.sleep(delay);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function validateHandoffResponse(stage, body, handoffId) {
|
|
102
|
+
if (stage === "task") {
|
|
103
|
+
const task = body.handoff;
|
|
104
|
+
if (task && task.id === handoffId && Array.isArray(task.received_ordinals))
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const counts = stage === "draft-chunks" ? ["accepted", "received", "requestedCount"] : ["accepted", "profiled", "requestedProducts", "selected", "requestedCount"];
|
|
109
|
+
if (counts.every((field) => Number.isInteger(body[field]) && Number(body[field]) >= 0) && typeof body.status === "string"
|
|
110
|
+
&& (stage !== "draft-chunks" || Array.isArray(body.receivedOrdinals)))
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
throw new SyntaxError("InvalidHandoffResponse");
|
|
114
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgentEmit } from "../agent/types.js";
|
|
2
2
|
import { type CanvasAgentConfig } from "../config.js";
|
|
3
3
|
import { type FlowCProductExecutionProfile } from "./product-profile.js";
|
|
4
|
+
import { type CommerceRequestDiagnostic } from "./commerce-http.js";
|
|
4
5
|
export declare const FLOW_C_CODEX_TURN_TIMEOUT_MS: number;
|
|
5
6
|
export declare const FLOW_C_SELECTED_BLUEPRINT_PROMPT_MAX_CHARS = 36000;
|
|
6
7
|
export declare const FLOW_C_SCRIPT_REWRITE_MAX_ATTEMPTS = 2;
|
|
@@ -24,6 +25,9 @@ type ScriptRecord = {
|
|
|
24
25
|
chunkSize?: number;
|
|
25
26
|
activeChunks?: number;
|
|
26
27
|
productProfiles?: FlowCProductExecutionProfile[];
|
|
28
|
+
pendingScriptJobs?: DraftJob[];
|
|
29
|
+
lastFailure?: CommerceRequestDiagnostic;
|
|
30
|
+
retryRequested?: boolean;
|
|
27
31
|
priorityAt?: string;
|
|
28
32
|
updatedAt: string;
|
|
29
33
|
};
|
|
@@ -147,9 +151,32 @@ type ScriptTask = {
|
|
|
147
151
|
received_ordinals: number[];
|
|
148
152
|
expires_at: string;
|
|
149
153
|
};
|
|
154
|
+
type DraftJob = {
|
|
155
|
+
ordinal: number;
|
|
156
|
+
productIndex: number;
|
|
157
|
+
sellingFormId: string;
|
|
158
|
+
script: string;
|
|
159
|
+
expectedCandidateRevision?: string;
|
|
160
|
+
masterScript?: string;
|
|
161
|
+
creativePlan?: Record<string, unknown>;
|
|
162
|
+
executionBindings?: Record<string, unknown>;
|
|
163
|
+
creativeTags?: Record<string, unknown>;
|
|
164
|
+
voiceProfile?: Record<string, string>;
|
|
165
|
+
qualityGate?: Record<string, string>;
|
|
166
|
+
segmentVoiceovers?: string[][];
|
|
167
|
+
segment?: {
|
|
168
|
+
script: string;
|
|
169
|
+
continuityMode: "continue" | "reset";
|
|
170
|
+
};
|
|
171
|
+
segments?: Array<{
|
|
172
|
+
script: string;
|
|
173
|
+
continuityMode: "continue" | "reset";
|
|
174
|
+
}>;
|
|
175
|
+
};
|
|
150
176
|
type ScriptChunkResult = {
|
|
151
177
|
error?: string;
|
|
152
178
|
terminal: boolean;
|
|
179
|
+
terminalKind?: "contract" | "transport" | "timeout" | "turn" | "delivery";
|
|
153
180
|
replanOrdinals?: number[];
|
|
154
181
|
};
|
|
155
182
|
/** 本机持久化的 Flow C 控制器:脚本交给本机 Codex,视频直接落盘。 */
|
|
@@ -214,22 +241,12 @@ export declare class WorkflowManager {
|
|
|
214
241
|
receivedOrdinals?: number[];
|
|
215
242
|
requestedCount: number;
|
|
216
243
|
status: string;
|
|
217
|
-
} & {
|
|
218
|
-
error?: string;
|
|
219
|
-
code?: string;
|
|
220
|
-
resetOrdinals?: unknown;
|
|
221
|
-
rewriteOrdinals?: unknown;
|
|
222
244
|
}>;
|
|
223
245
|
submitCandidateChunk(idValue: unknown, groupsValue: unknown): Promise<{
|
|
224
246
|
accepted: number;
|
|
225
247
|
selected: number;
|
|
226
248
|
requestedCount: number;
|
|
227
249
|
selectedCandidates: SelectedCandidate[];
|
|
228
|
-
} & {
|
|
229
|
-
error?: string;
|
|
230
|
-
code?: string;
|
|
231
|
-
resetOrdinals?: unknown;
|
|
232
|
-
rewriteOrdinals?: unknown;
|
|
233
250
|
}>;
|
|
234
251
|
submitProductProfileChunk(idValue: unknown, profilesValue: unknown[], contractVersion?: string): Promise<{
|
|
235
252
|
accepted: number;
|
|
@@ -238,12 +255,8 @@ export declare class WorkflowManager {
|
|
|
238
255
|
selected: number;
|
|
239
256
|
requestedCount: number;
|
|
240
257
|
status: string;
|
|
241
|
-
} & {
|
|
242
|
-
error?: string;
|
|
243
|
-
code?: string;
|
|
244
|
-
resetOrdinals?: unknown;
|
|
245
|
-
rewriteOrdinals?: unknown;
|
|
246
258
|
}>;
|
|
259
|
+
private scriptRequestOptions;
|
|
247
260
|
downloadState(): {
|
|
248
261
|
configured: boolean;
|
|
249
262
|
directoryName: string | undefined;
|
|
@@ -371,6 +384,11 @@ export declare function terminalScriptChunkError(results: Array<{
|
|
|
371
384
|
error?: string;
|
|
372
385
|
terminal?: boolean;
|
|
373
386
|
}>): string;
|
|
387
|
+
export declare function terminalScriptChunkFailure<T extends {
|
|
388
|
+
error?: string;
|
|
389
|
+
terminal?: boolean;
|
|
390
|
+
terminalKind?: ScriptChunkResult["terminalKind"];
|
|
391
|
+
}>(results: T[]): T | undefined;
|
|
374
392
|
export declare function scriptCreativeReplanOrdinals(results: unknown[]): number[];
|
|
375
393
|
/** Keep a mixed reset signal after an exact-duplicate rewrite finishes. */
|
|
376
394
|
export declare function preserveScriptRecoveryReplans(result: ScriptChunkResult, error: unknown): ScriptChunkResult;
|
|
@@ -416,4 +434,5 @@ export declare function nextScriptPipelineWave(task: Pick<ScriptTask, "requested
|
|
|
416
434
|
};
|
|
417
435
|
export declare function immediateScriptOrdinals(task: Pick<ScriptTask, "selected_candidates">, receivedOrdinals: number[], candidateOrdinals: number[]): number[];
|
|
418
436
|
export declare function missingOrdinals(total: number, received: number[]): number[];
|
|
437
|
+
export declare function mergeProductProfiles(local?: FlowCProductExecutionProfile[], confirmed?: FlowCProductExecutionProfile[]): FlowCProductExecutionProfile[];
|
|
419
438
|
export {};
|