@xiaohhhh1/canvas-agent 0.4.75 → 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/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 +29 -16
- package/dist/workflow/manager.js +139 -64
- package/package.json +1 -1
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,10 +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;
|
|
153
|
-
terminalKind?: "contract" | "transport" | "timeout" | "turn";
|
|
179
|
+
terminalKind?: "contract" | "transport" | "timeout" | "turn" | "delivery";
|
|
154
180
|
replanOrdinals?: number[];
|
|
155
181
|
};
|
|
156
182
|
/** 本机持久化的 Flow C 控制器:脚本交给本机 Codex,视频直接落盘。 */
|
|
@@ -215,22 +241,12 @@ export declare class WorkflowManager {
|
|
|
215
241
|
receivedOrdinals?: number[];
|
|
216
242
|
requestedCount: number;
|
|
217
243
|
status: string;
|
|
218
|
-
} & {
|
|
219
|
-
error?: string;
|
|
220
|
-
code?: string;
|
|
221
|
-
resetOrdinals?: unknown;
|
|
222
|
-
rewriteOrdinals?: unknown;
|
|
223
244
|
}>;
|
|
224
245
|
submitCandidateChunk(idValue: unknown, groupsValue: unknown): Promise<{
|
|
225
246
|
accepted: number;
|
|
226
247
|
selected: number;
|
|
227
248
|
requestedCount: number;
|
|
228
249
|
selectedCandidates: SelectedCandidate[];
|
|
229
|
-
} & {
|
|
230
|
-
error?: string;
|
|
231
|
-
code?: string;
|
|
232
|
-
resetOrdinals?: unknown;
|
|
233
|
-
rewriteOrdinals?: unknown;
|
|
234
250
|
}>;
|
|
235
251
|
submitProductProfileChunk(idValue: unknown, profilesValue: unknown[], contractVersion?: string): Promise<{
|
|
236
252
|
accepted: number;
|
|
@@ -239,12 +255,8 @@ export declare class WorkflowManager {
|
|
|
239
255
|
selected: number;
|
|
240
256
|
requestedCount: number;
|
|
241
257
|
status: string;
|
|
242
|
-
} & {
|
|
243
|
-
error?: string;
|
|
244
|
-
code?: string;
|
|
245
|
-
resetOrdinals?: unknown;
|
|
246
|
-
rewriteOrdinals?: unknown;
|
|
247
258
|
}>;
|
|
259
|
+
private scriptRequestOptions;
|
|
248
260
|
downloadState(): {
|
|
249
261
|
configured: boolean;
|
|
250
262
|
directoryName: string | undefined;
|
|
@@ -422,4 +434,5 @@ export declare function nextScriptPipelineWave(task: Pick<ScriptTask, "requested
|
|
|
422
434
|
};
|
|
423
435
|
export declare function immediateScriptOrdinals(task: Pick<ScriptTask, "selected_candidates">, receivedOrdinals: number[], candidateOrdinals: number[]): number[];
|
|
424
436
|
export declare function missingOrdinals(total: number, received: number[]): number[];
|
|
437
|
+
export declare function mergeProductProfiles(local?: FlowCProductExecutionProfile[], confirmed?: FlowCProductExecutionProfile[]): FlowCProductExecutionProfile[];
|
|
425
438
|
export {};
|
package/dist/workflow/manager.js
CHANGED
|
@@ -13,6 +13,7 @@ import { FLOW_C_SCRIPT_CHUNK_MAX, flowCScriptChunks, flowCScriptChunkSizes } fro
|
|
|
13
13
|
import { FLOW_C_CREATIVE_CANDIDATE_CONTRACT_VERSION, flowCCreativeCandidateOutputSchema, parseFlowCCreativeCandidateOutput } from "./creative-candidates.js";
|
|
14
14
|
import { FLOW_C_LEGACY_PRODUCT_EXECUTION_PROFILE_CONTRACT_VERSION, FLOW_C_PRODUCT_EXECUTION_PROFILE_CONTRACT_VERSION, flowCProductExecutionProfileOutputSchema, parseFlowCProductExecutionProfileOutput } from "./product-profile.js";
|
|
15
15
|
import { flowCScriptOutputSchema, parseFlowCScriptOutput } from "./script-output.js";
|
|
16
|
+
import { commerceJson, CommerceRequestError } from "./commerce-http.js";
|
|
16
17
|
const STATE_FILE = path.join(CONFIG_DIR, "workflow-state.json");
|
|
17
18
|
export const FLOW_C_CODEX_TURN_TIMEOUT_MS = 8 * 60 * 1000;
|
|
18
19
|
export const FLOW_C_SELECTED_BLUEPRINT_PROMPT_MAX_CHARS = 36_000;
|
|
@@ -67,6 +68,8 @@ export class WorkflowManager {
|
|
|
67
68
|
chunkSize: previous?.chunkSize,
|
|
68
69
|
activeChunks: 0,
|
|
69
70
|
productProfiles: previous?.productProfiles || [],
|
|
71
|
+
pendingScriptJobs: previous?.pendingScriptJobs || [],
|
|
72
|
+
lastFailure: previous?.lastFailure,
|
|
70
73
|
priorityAt: now(),
|
|
71
74
|
message: previous?.status === "complete" ? previous.message : "已进入本机 Codex 队列",
|
|
72
75
|
updatedAt: now(),
|
|
@@ -78,6 +81,15 @@ export class WorkflowManager {
|
|
|
78
81
|
retryScript(idValue) {
|
|
79
82
|
const id = workflowId(idValue, "脚本交接 ID");
|
|
80
83
|
const record = this.scriptRecord(id);
|
|
84
|
+
if (this.runningScripts.has(id)) {
|
|
85
|
+
record.retryRequested = true;
|
|
86
|
+
record.priorityAt = now();
|
|
87
|
+
record.message = "当前任务仍在处理,结束后优先重试";
|
|
88
|
+
record.updatedAt = now();
|
|
89
|
+
this.save();
|
|
90
|
+
return this.scriptStatus(id);
|
|
91
|
+
}
|
|
92
|
+
delete record.retryRequested;
|
|
81
93
|
record.status = "queued";
|
|
82
94
|
record.activeChunks = 0;
|
|
83
95
|
// A failed manual attempt may leave a very large or interrupted thread.
|
|
@@ -97,12 +109,13 @@ export class WorkflowManager {
|
|
|
97
109
|
/** MCP 读取服务端持久化的完整任务,不向模型暴露令牌。 */
|
|
98
110
|
async scriptTask(idValue) {
|
|
99
111
|
const record = this.scriptRecord(workflowId(idValue, "脚本交接 ID"));
|
|
100
|
-
const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/task`, record.accessToken, "x-workflow-handoff-token");
|
|
112
|
+
const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/task`, record.accessToken, "x-workflow-handoff-token", {}, this.scriptRequestOptions(record));
|
|
101
113
|
const task = data.handoff;
|
|
102
114
|
record.requestedCount = Number(task.requested_count || 0);
|
|
103
|
-
record.receivedOrdinals =
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
record.receivedOrdinals = [...new Set([...record.receivedOrdinals, ...task.received_ordinals.map(Number).filter(Number.isInteger)])].sort((left, right) => left - right);
|
|
116
|
+
task.received_ordinals = record.receivedOrdinals;
|
|
117
|
+
record.productProfiles = mergeProductProfiles(record.productProfiles, task.product_execution_profiles);
|
|
118
|
+
record.pendingScriptJobs = (record.pendingScriptJobs || []).filter((job) => !record.receivedOrdinals.includes(job.ordinal));
|
|
106
119
|
record.expiresAt = task.expires_at;
|
|
107
120
|
record.updatedAt = now();
|
|
108
121
|
this.save();
|
|
@@ -114,7 +127,13 @@ export class WorkflowManager {
|
|
|
114
127
|
if (!Array.isArray(jobsValue) || !jobsValue.length || jobsValue.length > FLOW_C_SCRIPT_CHUNK_MAX)
|
|
115
128
|
throw new Error(`每段必须包含 1–${FLOW_C_SCRIPT_CHUNK_MAX} 条脚本`);
|
|
116
129
|
const jobs = jobsValue;
|
|
117
|
-
const
|
|
130
|
+
const pending = new Map((record.pendingScriptJobs || []).map((job) => [job.ordinal, job]));
|
|
131
|
+
for (const job of jobs)
|
|
132
|
+
if (!record.receivedOrdinals.includes(job.ordinal))
|
|
133
|
+
pending.set(job.ordinal, structuredClone(job));
|
|
134
|
+
record.pendingScriptJobs = [...pending.values()].sort((left, right) => left.ordinal - right.ordinal);
|
|
135
|
+
this.save();
|
|
136
|
+
const data = await commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/draft-chunks`, record.accessToken, "x-workflow-handoff-token", { method: "POST", body: JSON.stringify({ jobs }) }, this.scriptRequestOptions(record));
|
|
118
137
|
if (Array.isArray(data.receivedOrdinals))
|
|
119
138
|
record.receivedOrdinals = [...new Set([...record.receivedOrdinals, ...data.receivedOrdinals.map(Number).filter(Number.isInteger)])].sort((left, right) => left - right);
|
|
120
139
|
else {
|
|
@@ -125,6 +144,7 @@ export class WorkflowManager {
|
|
|
125
144
|
record.receivedOrdinals = [...accepted].sort((a, b) => a - b);
|
|
126
145
|
}
|
|
127
146
|
record.requestedCount = Number(data.requestedCount || record.requestedCount);
|
|
147
|
+
record.pendingScriptJobs = (record.pendingScriptJobs || []).filter((job) => !record.receivedOrdinals.includes(job.ordinal));
|
|
128
148
|
record.status = data.status === "ready" ? "complete" : "running";
|
|
129
149
|
record.message = data.status === "ready" ? `全部 ${record.requestedCount} 条高质量脚本已回传` : `已回传 ${data.received}/${data.requestedCount} 条脚本`;
|
|
130
150
|
record.updatedAt = now();
|
|
@@ -135,13 +155,21 @@ export class WorkflowManager {
|
|
|
135
155
|
const record = this.scriptRecord(workflowId(idValue, "脚本交接 ID"));
|
|
136
156
|
if (!Array.isArray(groupsValue) || !groupsValue.length || groupsValue.length > 10)
|
|
137
157
|
throw new Error("每个候选子批必须包含 1–10 个 ordinal 组");
|
|
138
|
-
return commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/candidate-chunks`, record.accessToken, "x-workflow-handoff-token", { method: "POST", body: JSON.stringify({ contractVersion: FLOW_C_CREATIVE_CANDIDATE_CONTRACT_VERSION, groups: groupsValue }) });
|
|
158
|
+
return commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/candidate-chunks`, record.accessToken, "x-workflow-handoff-token", { method: "POST", body: JSON.stringify({ contractVersion: FLOW_C_CREATIVE_CANDIDATE_CONTRACT_VERSION, groups: groupsValue }) }, this.scriptRequestOptions(record));
|
|
139
159
|
}
|
|
140
160
|
async submitProductProfileChunk(idValue, profilesValue, contractVersion = FLOW_C_PRODUCT_EXECUTION_PROFILE_CONTRACT_VERSION) {
|
|
141
161
|
const record = this.scriptRecord(workflowId(idValue, "脚本交接 ID"));
|
|
142
162
|
if (!Array.isArray(profilesValue) || profilesValue.length > 10)
|
|
143
163
|
throw new Error("每个商品执行档案子批最多 10 个产品");
|
|
144
|
-
return commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/product-profiles`, record.accessToken, "x-workflow-handoff-token", { method: "POST", body: JSON.stringify({ contractVersion, profiles: profilesValue }) });
|
|
164
|
+
return commerceJson(`${record.apiBase}/workflow-script-handoffs/${encodeURIComponent(record.id)}/product-profiles`, record.accessToken, "x-workflow-handoff-token", { method: "POST", body: JSON.stringify({ contractVersion, profiles: profilesValue }) }, this.scriptRequestOptions(record));
|
|
165
|
+
}
|
|
166
|
+
scriptRequestOptions(record) {
|
|
167
|
+
return { expiresAt: record.expiresAt, onFailure: (diagnostic) => {
|
|
168
|
+
record.lastFailure = diagnostic;
|
|
169
|
+
record.updatedAt = now();
|
|
170
|
+
this.save();
|
|
171
|
+
logger.failure("Flow C center request failed", { handoffId: record.id, ...diagnostic });
|
|
172
|
+
} };
|
|
145
173
|
}
|
|
146
174
|
downloadState() {
|
|
147
175
|
return {
|
|
@@ -260,6 +288,7 @@ export class WorkflowManager {
|
|
|
260
288
|
const record = this.scriptRecord(id);
|
|
261
289
|
try {
|
|
262
290
|
delete record.priorityAt;
|
|
291
|
+
delete record.retryRequested;
|
|
263
292
|
record.status = "running";
|
|
264
293
|
record.message = "正在读取完整产品清单";
|
|
265
294
|
record.updatedAt = now();
|
|
@@ -311,6 +340,8 @@ export class WorkflowManager {
|
|
|
311
340
|
task = await this.scriptTask(id);
|
|
312
341
|
const terminalFailure = terminalScriptChunkFailure(results);
|
|
313
342
|
if (terminalFailure) {
|
|
343
|
+
if (terminalFailure.terminalKind === "delivery")
|
|
344
|
+
throw new Error(terminalFailure.error);
|
|
314
345
|
if (terminalFailure.terminalKind === "transport")
|
|
315
346
|
throw new Error(`创意或脚本阶段的本机 Codex 进程连续异常且自动恢复未成功,已停止当前任务且未提交缺失脚本(${terminalFailure.error})`);
|
|
316
347
|
throw new Error(`创意或脚本结构化契约被 Codex 拒绝,已停止自动重试(${terminalFailure.error})`);
|
|
@@ -324,6 +355,8 @@ export class WorkflowManager {
|
|
|
324
355
|
const replanResults = await Promise.all(replanOrdinals.map((ordinal) => this.runCandidateChunk(id, task, [ordinal], workspace.workspacePath)));
|
|
325
356
|
const replanTerminalFailure = terminalScriptChunkFailure(replanResults);
|
|
326
357
|
if (replanTerminalFailure) {
|
|
358
|
+
if (replanTerminalFailure.terminalKind === "delivery")
|
|
359
|
+
throw new Error(replanTerminalFailure.error);
|
|
327
360
|
if (replanTerminalFailure.terminalKind === "transport")
|
|
328
361
|
throw new Error(`创意重新选题时本机 Codex 进程异常且自动恢复未成功,已停止当前任务(${replanTerminalFailure.error})`);
|
|
329
362
|
throw new Error(`创意重新选题被 Codex 拒绝,已停止自动重试(${replanTerminalFailure.error})`);
|
|
@@ -358,6 +391,8 @@ export class WorkflowManager {
|
|
|
358
391
|
task = await this.scriptTask(id);
|
|
359
392
|
const terminalFailure = terminalScriptChunkFailure(results);
|
|
360
393
|
if (terminalFailure) {
|
|
394
|
+
if (terminalFailure.terminalKind === "delivery")
|
|
395
|
+
throw new Error(terminalFailure.error);
|
|
361
396
|
if (terminalFailure.terminalKind === "transport")
|
|
362
397
|
throw new Error(`本机 Codex 脚本引擎连续异常且自动恢复未成功,已停止当前任务且未提交缺失脚本。诊断已保存在本机 Agent 日志中(${terminalFailure.error})`);
|
|
363
398
|
throw new Error(`本机脚本结构化契约被 Codex 拒绝,已停止自动重试且未提交缺失脚本。请先升级或修复 Canvas Agent,再手动重试(${terminalFailure.error})`);
|
|
@@ -371,6 +406,8 @@ export class WorkflowManager {
|
|
|
371
406
|
const replanResults = await Promise.all(replanOrdinals.map((ordinal) => this.runCandidateChunk(id, task, [ordinal], workspace.workspacePath)));
|
|
372
407
|
const replanTerminalFailure = terminalScriptChunkFailure(replanResults);
|
|
373
408
|
if (replanTerminalFailure) {
|
|
409
|
+
if (replanTerminalFailure.terminalKind === "delivery")
|
|
410
|
+
throw new Error(replanTerminalFailure.error);
|
|
374
411
|
if (replanTerminalFailure.terminalKind === "transport")
|
|
375
412
|
throw new Error(`创意重新选题时本机 Codex 进程异常且自动恢复未成功,已停止当前任务(${replanTerminalFailure.error})`);
|
|
376
413
|
throw new Error(`创意重新选题被 Codex 拒绝,已停止自动重试(${replanTerminalFailure.error})`);
|
|
@@ -405,15 +442,26 @@ export class WorkflowManager {
|
|
|
405
442
|
throw new Error(`中心校验尚未通过(${record.receivedOrdinals.length}/${task.requested_count} 条),请点击重试`);
|
|
406
443
|
}
|
|
407
444
|
catch (error) {
|
|
408
|
-
|
|
445
|
+
const expired = error instanceof ExpiredCapabilityError || error instanceof CommerceRequestError && error.code === "WORKFLOW_CAPABILITY_EXPIRED"
|
|
446
|
+
|| record.lastFailure?.name === "CapabilityExpired" && Date.parse(record.expiresAt || "") <= Date.now();
|
|
447
|
+
record.status = expired ? "expired" : "error";
|
|
409
448
|
record.message = error instanceof Error ? error.message : "本机 Codex 处理失败";
|
|
410
449
|
logger.warn("Local Flow C script handoff paused", { handoffId: id, error: record.message });
|
|
411
450
|
}
|
|
412
451
|
finally {
|
|
452
|
+
const retry = record.retryRequested && record.status !== "complete";
|
|
453
|
+
delete record.retryRequested;
|
|
454
|
+
if (retry) {
|
|
455
|
+
record.status = "queued";
|
|
456
|
+
delete record.threadId;
|
|
457
|
+
record.message = "已保存本机结果,正在优先重试";
|
|
458
|
+
}
|
|
413
459
|
record.updatedAt = now();
|
|
414
460
|
record.activeChunks = 0;
|
|
415
461
|
this.save();
|
|
416
462
|
this.runningScripts.delete(id);
|
|
463
|
+
if (retry)
|
|
464
|
+
this.scheduleScript(id);
|
|
417
465
|
}
|
|
418
466
|
}
|
|
419
467
|
/** Analyze each product once; image 1 owns identity and later images may only corroborate visible evidence. */
|
|
@@ -438,21 +486,37 @@ export class WorkflowManager {
|
|
|
438
486
|
&& missing.some((product) => (product.productImageUrlsInExactOrder || []).length > 1);
|
|
439
487
|
const profileConcurrency = includesSupportingImages ? 1 : FLOW_C_CODEX_WORKER_CONCURRENCY;
|
|
440
488
|
const workerCount = Math.min(missing.length, Math.max(1, profileConcurrency));
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
489
|
+
let stopped = false;
|
|
490
|
+
let firstFailure;
|
|
491
|
+
// Do not release the handoff lock while a sibling model turn can
|
|
492
|
+
// still produce a result. A quick retry must see its durable cache.
|
|
493
|
+
await Promise.allSettled(Array.from({ length: workerCount }, async () => {
|
|
494
|
+
try {
|
|
495
|
+
while (!stopped && nextProduct < missing.length) {
|
|
496
|
+
const product = missing[nextProduct++];
|
|
497
|
+
const productIndex = Number(product.productIndex);
|
|
498
|
+
let profile = cached.get(productIndex);
|
|
499
|
+
if (!profile) {
|
|
500
|
+
profile = await this.runProductExecutionProfile(id, product, cwd, profileContractVersion);
|
|
501
|
+
cached.set(productIndex, profile);
|
|
502
|
+
record.productProfiles = [...cached.values()].sort((left, right) => left.productIndex - right.productIndex);
|
|
503
|
+
record.updatedAt = now();
|
|
504
|
+
this.save();
|
|
505
|
+
}
|
|
506
|
+
if (stopped)
|
|
507
|
+
return;
|
|
508
|
+
await this.submitProductProfileChunk(id, [profile], profileContractVersion);
|
|
452
509
|
}
|
|
453
|
-
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
if (!stopped)
|
|
513
|
+
firstFailure = error;
|
|
514
|
+
stopped = true;
|
|
515
|
+
throw error;
|
|
454
516
|
}
|
|
455
517
|
}));
|
|
518
|
+
if (stopped)
|
|
519
|
+
throw firstFailure;
|
|
456
520
|
task = await this.scriptTask(id);
|
|
457
521
|
}
|
|
458
522
|
const profiled = Number(task.product_profile_stage?.completed || task.product_execution_profiles?.length || 0);
|
|
@@ -532,33 +596,44 @@ export class WorkflowManager {
|
|
|
532
596
|
}
|
|
533
597
|
/** 一个 chunk 使用独立线程;解析与中心持久化失败不会影响并行 lane。 */
|
|
534
598
|
async runScriptChunk(id, task, ordinals, cwd, rewriteAttempt = 0, revisionAttempt = 0) {
|
|
535
|
-
const durationSeconds = Number(task.duration_seconds || 10);
|
|
536
|
-
let prompt;
|
|
537
|
-
try {
|
|
538
|
-
prompt = scriptChunkPrompt(id, task, ordinals, rewriteAttempt);
|
|
539
|
-
}
|
|
540
|
-
catch (error) {
|
|
541
|
-
if (isFlowCPromptPayloadTooLarge(error))
|
|
542
|
-
return { error: error.message, terminal: false };
|
|
543
|
-
throw error;
|
|
544
|
-
}
|
|
545
|
-
const result = await runCodexWorkflowTurn(prompt, this.emit, {
|
|
546
|
-
cwd,
|
|
547
|
-
permissionMode: "full",
|
|
548
|
-
timeoutMs: FLOW_C_CODEX_TURN_TIMEOUT_MS,
|
|
549
|
-
outputSchema: flowCScriptOutputSchema(durationSeconds, ordinals.length),
|
|
550
|
-
onThread: (threadId) => { this.scriptRecord(id).threadId = threadId; this.save(); },
|
|
551
|
-
onWorkerStart: () => { const record = this.scriptRecord(id); record.activeChunks = Number(record.activeChunks || 0) + 1; record.updatedAt = now(); this.save(); },
|
|
552
|
-
onWorkerFinish: () => { const record = this.scriptRecord(id); record.activeChunks = Math.max(0, Number(record.activeChunks || 0) - 1); record.updatedAt = now(); this.save(); },
|
|
553
|
-
});
|
|
554
|
-
this.emitScriptStage(id, ordinals, "queue_wait", result.timings.queueWaitMs);
|
|
555
|
-
this.emitScriptStage(id, ordinals, "thread_start", result.timings.threadStartMs);
|
|
556
|
-
this.emitScriptStage(id, ordinals, "model", result.timings.modelMs);
|
|
557
|
-
if (!result.ok)
|
|
558
|
-
return { error: result.error, terminal: !result.retryable, ...(!result.retryable ? { terminalKind: result.failureKind } : {}) };
|
|
559
|
-
if (!result.text)
|
|
560
|
-
return { error: "Codex 未返回可用的结构化脚本", terminal: false };
|
|
561
599
|
try {
|
|
600
|
+
const record = this.scriptRecord(id);
|
|
601
|
+
ordinals = ordinals.filter((ordinal) => !record.receivedOrdinals.includes(ordinal));
|
|
602
|
+
if (!ordinals.length)
|
|
603
|
+
return { terminal: false };
|
|
604
|
+
const cached = (record.pendingScriptJobs || []).filter((job) => ordinals.includes(job.ordinal));
|
|
605
|
+
if (cached.length) {
|
|
606
|
+
await this.submitGeneratedScriptJobs(id, cached);
|
|
607
|
+
ordinals = ordinals.filter((ordinal) => !record.receivedOrdinals.includes(ordinal));
|
|
608
|
+
if (!ordinals.length)
|
|
609
|
+
return { terminal: false };
|
|
610
|
+
}
|
|
611
|
+
const durationSeconds = Number(task.duration_seconds || 10);
|
|
612
|
+
let prompt;
|
|
613
|
+
try {
|
|
614
|
+
prompt = scriptChunkPrompt(id, task, ordinals, rewriteAttempt);
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
if (isFlowCPromptPayloadTooLarge(error))
|
|
618
|
+
return { error: error.message, terminal: false };
|
|
619
|
+
throw error;
|
|
620
|
+
}
|
|
621
|
+
const result = await runCodexWorkflowTurn(prompt, this.emit, {
|
|
622
|
+
cwd,
|
|
623
|
+
permissionMode: "full",
|
|
624
|
+
timeoutMs: FLOW_C_CODEX_TURN_TIMEOUT_MS,
|
|
625
|
+
outputSchema: flowCScriptOutputSchema(durationSeconds, ordinals.length),
|
|
626
|
+
onThread: (threadId) => { this.scriptRecord(id).threadId = threadId; this.save(); },
|
|
627
|
+
onWorkerStart: () => { const record = this.scriptRecord(id); record.activeChunks = Number(record.activeChunks || 0) + 1; record.updatedAt = now(); this.save(); },
|
|
628
|
+
onWorkerFinish: () => { const record = this.scriptRecord(id); record.activeChunks = Math.max(0, Number(record.activeChunks || 0) - 1); record.updatedAt = now(); this.save(); },
|
|
629
|
+
});
|
|
630
|
+
this.emitScriptStage(id, ordinals, "queue_wait", result.timings.queueWaitMs);
|
|
631
|
+
this.emitScriptStage(id, ordinals, "thread_start", result.timings.threadStartMs);
|
|
632
|
+
this.emitScriptStage(id, ordinals, "model", result.timings.modelMs);
|
|
633
|
+
if (!result.ok)
|
|
634
|
+
return { error: result.error, terminal: !result.retryable, ...(!result.retryable ? { terminalKind: result.failureKind } : {}) };
|
|
635
|
+
if (!result.text)
|
|
636
|
+
return { error: "Codex 未返回可用的结构化脚本", terminal: false };
|
|
562
637
|
const parseStartedAt = Date.now();
|
|
563
638
|
const selected = selectedCandidatesForOrdinals(task, ordinals);
|
|
564
639
|
const jobs = parseFlowCScriptOutput(result.text, ordinals).map((job) => ({
|
|
@@ -574,6 +649,14 @@ export class WorkflowManager {
|
|
|
574
649
|
return { terminal: false };
|
|
575
650
|
}
|
|
576
651
|
catch (error) {
|
|
652
|
+
// Only explicit semantic rejection may discard the affected saved output.
|
|
653
|
+
// Network/response loss, auth failures and validation stops retain it.
|
|
654
|
+
const invalidated = [...candidateRevisionChangedOrdinals(error), ...scriptRewriteOrdinals(error), ...creativeReplanOrdinals(error)];
|
|
655
|
+
if (invalidated.length) {
|
|
656
|
+
const record = this.scriptRecord(id);
|
|
657
|
+
record.pendingScriptJobs = (record.pendingScriptJobs || []).filter((job) => !invalidated.includes(job.ordinal));
|
|
658
|
+
this.save();
|
|
659
|
+
}
|
|
577
660
|
const revisionOrdinals = candidateRevisionChangedOrdinals(error);
|
|
578
661
|
if (revisionOrdinals.length && !creativeReplanOrdinals(error).length) {
|
|
579
662
|
if (revisionAttempt >= FLOW_C_CANDIDATE_REVISION_MAX_ATTEMPTS) {
|
|
@@ -632,6 +715,8 @@ export class WorkflowManager {
|
|
|
632
715
|
};
|
|
633
716
|
return preserveScriptRecoveryReplans(rewriteResult, error);
|
|
634
717
|
}
|
|
718
|
+
if (error instanceof CommerceRequestError && !creativeReplanOrdinals(error).length)
|
|
719
|
+
return { error: error.message, terminal: true, terminalKind: "delivery" };
|
|
635
720
|
return {
|
|
636
721
|
error: error instanceof Error ? `结构化脚本未通过本机校验:${error.message}` : "结构化脚本未通过本机校验",
|
|
637
722
|
terminal: terminalScriptValidationError(error),
|
|
@@ -653,7 +738,7 @@ export class WorkflowManager {
|
|
|
653
738
|
return;
|
|
654
739
|
}
|
|
655
740
|
catch (error) {
|
|
656
|
-
if (jobs.length === 1)
|
|
741
|
+
if (jobs.length === 1 || error instanceof CommerceRequestError && ![400, 409, 422].includes(error.status || 0))
|
|
657
742
|
throw error;
|
|
658
743
|
let accepted = 0;
|
|
659
744
|
const failures = [];
|
|
@@ -663,6 +748,8 @@ export class WorkflowManager {
|
|
|
663
748
|
accepted += 1;
|
|
664
749
|
}
|
|
665
750
|
catch (jobError) {
|
|
751
|
+
if (jobError instanceof CommerceRequestError && ![400, 409, 422].includes(jobError.status || 0))
|
|
752
|
+
throw jobError;
|
|
666
753
|
failures.push(jobError);
|
|
667
754
|
}
|
|
668
755
|
}
|
|
@@ -1326,23 +1413,11 @@ function safeDownloadUrl(value) { const url = new URL(String(value || "")); if (
|
|
|
1326
1413
|
throw new Error("下载地址必须使用 HTTPS"); return url.toString(); }
|
|
1327
1414
|
function safeName(value) { return String(value || "市场").replace(/[<>:"/\\|?*\u0000-\u001f]/g, "-").replace(/[. ]+$/g, "").slice(0, 60) || "市场"; }
|
|
1328
1415
|
function now() { return new Date().toISOString(); }
|
|
1329
|
-
|
|
1330
|
-
const
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
headers.set("content-type", "application/json");
|
|
1335
|
-
const response = await fetch(url, { ...init, headers, signal: AbortSignal.timeout(120_000) });
|
|
1336
|
-
const body = await response.json().catch(() => ({}));
|
|
1337
|
-
if (!response.ok) {
|
|
1338
|
-
const error = new Error(body.error || `中心接口返回 ${response.status}`);
|
|
1339
|
-
error.status = response.status;
|
|
1340
|
-
error.code = body.code;
|
|
1341
|
-
error.resetOrdinals = body.resetOrdinals;
|
|
1342
|
-
error.rewriteOrdinals = body.rewriteOrdinals;
|
|
1343
|
-
throw error;
|
|
1344
|
-
}
|
|
1345
|
-
return body;
|
|
1416
|
+
export function mergeProductProfiles(local = [], confirmed = []) {
|
|
1417
|
+
const merged = new Map(local.map((profile) => [Number(profile.productIndex), profile]));
|
|
1418
|
+
for (const profile of confirmed || [])
|
|
1419
|
+
merged.set(Number(profile.productIndex), profile);
|
|
1420
|
+
return [...merged.values()].sort((left, right) => left.productIndex - right.productIndex);
|
|
1346
1421
|
}
|
|
1347
1422
|
function loadState() {
|
|
1348
1423
|
try {
|