ultracode-for-codex 0.2.0
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/README.md +182 -0
- package/ULTRACODE_INSTALL.md +133 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +616 -0
- package/dist/codex/env.d.ts +2 -0
- package/dist/codex/env.js +45 -0
- package/dist/codex/subagent-backend.d.ts +72 -0
- package/dist/codex/subagent-backend.js +685 -0
- package/dist/runtime/async-queue.d.ts +10 -0
- package/dist/runtime/async-queue.js +55 -0
- package/dist/runtime/types.d.ts +61 -0
- package/dist/runtime/types.js +18 -0
- package/dist/runtime/workflow-journal.d.ts +135 -0
- package/dist/runtime/workflow-journal.js +681 -0
- package/dist/runtime/workflow-runtime.d.ts +266 -0
- package/dist/runtime/workflow-runtime.js +3280 -0
- package/dist/settings.d.ts +38 -0
- package/dist/settings.js +153 -0
- package/dist/ultracode-install-guide.d.ts +4 -0
- package/dist/ultracode-install-guide.js +22 -0
- package/docs/ultracode-p3a-journal-design.md +78 -0
- package/docs/ultracode-p3b-resume-cache.md +43 -0
- package/docs/ultracode-p3c-worktree-isolation.md +60 -0
- package/package.json +77 -0
- package/postinstall.mjs +23 -0
- package/settings.json +20 -0
- package/skills/ultracode-for-codex/SKILL.md +102 -0
- package/skills/ultracode-for-codex/agents/openai.yaml +4 -0
|
@@ -0,0 +1,3280 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { chmod, mkdir, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { createContext, runInContext } from 'node:vm';
|
|
8
|
+
import { UltracodeRequestError, estimateTokens } from './types.js';
|
|
9
|
+
import { WORKFLOW_JOURNAL_GENESIS_AGENT_CALL_KEY, WORKFLOW_JOURNAL_WRITE_FAILED_REASON, WorkflowJournalError, WorkflowJournalWriter, cleanupWorkflowJournalTranscriptDir, computeWorkflowAgentCallKey, isWorkflowJournalError, normalizeJournalJsonValue, readWorkflowJournal, workflowJournalPath, } from './workflow-journal.js';
|
|
10
|
+
const MAX_SCRIPT_BYTES = 64 * 1024;
|
|
11
|
+
const MAX_AGENT_CALLS = 1000;
|
|
12
|
+
const MAX_PARALLELISM = 16;
|
|
13
|
+
const DEFAULT_AGENT_STALL_TIMEOUT_MS = 180_000;
|
|
14
|
+
const DEFAULT_AGENT_STALL_RETRY_LIMIT = 5;
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const PROJECT_WORKFLOW_DIRS = ['.codex/workflows'];
|
|
17
|
+
const WORKFLOW_PERMISSION_REQUIRED_SOURCES = new Set(['script_path', 'project', 'user', 'plugin']);
|
|
18
|
+
const WORKFLOW_PERMISSION_STORE_VERSION = 1;
|
|
19
|
+
const WORKFLOW_PERMISSION_REVIEW_VERSION = 2;
|
|
20
|
+
const WORKFLOW_STATE_DIR_MODE = 0o700;
|
|
21
|
+
const WORKFLOW_STATE_FILE_MODE = 0o600;
|
|
22
|
+
const WORKFLOW_TOOL_NAMES = new Set(['Workflow', 'RunWorkflow']);
|
|
23
|
+
const STRUCTURED_OUTPUT_TOOL_NAME = 'StructuredOutput';
|
|
24
|
+
const WORKFLOW_INPUT_PARAM = 'workflow';
|
|
25
|
+
const WORKFLOW_JOURNAL_PUBLIC_FAILURE_MESSAGE = 'Workflow journal write failed.';
|
|
26
|
+
const WORKFLOW_STABLE_FAILURE_CODES = new Set([
|
|
27
|
+
WORKFLOW_JOURNAL_WRITE_FAILED_REASON,
|
|
28
|
+
'runtime_closed',
|
|
29
|
+
'workflow_aborted',
|
|
30
|
+
'workflow_agent_stalled',
|
|
31
|
+
'workflow_input_invalid',
|
|
32
|
+
'workflow_meta_invalid',
|
|
33
|
+
'workflow_permission_denied',
|
|
34
|
+
'workflow_resume_running',
|
|
35
|
+
'workflow_script_nondeterministic',
|
|
36
|
+
'workflow_structured_output_failed',
|
|
37
|
+
]);
|
|
38
|
+
const FORBIDDEN_HOST_PROPERTY_NAMES = new Set(['constructor', 'prototype', '__proto__', 'process', 'require', 'globalThis', 'global', 'module', 'exports']);
|
|
39
|
+
const JSON_SCHEMA_TYPES = new Set(['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']);
|
|
40
|
+
const JSON_SCHEMA_KEYS = new Set([
|
|
41
|
+
'$schema',
|
|
42
|
+
'additionalProperties',
|
|
43
|
+
'description',
|
|
44
|
+
'enum',
|
|
45
|
+
'items',
|
|
46
|
+
'maximum',
|
|
47
|
+
'maxItems',
|
|
48
|
+
'maxLength',
|
|
49
|
+
'minimum',
|
|
50
|
+
'minItems',
|
|
51
|
+
'minLength',
|
|
52
|
+
'properties',
|
|
53
|
+
'required',
|
|
54
|
+
'title',
|
|
55
|
+
'type',
|
|
56
|
+
]);
|
|
57
|
+
const DEFAULT_BUILTIN_WORKFLOWS = [
|
|
58
|
+
{
|
|
59
|
+
name: 'code-review',
|
|
60
|
+
script: `export const meta = {
|
|
61
|
+
name: "code-review",
|
|
62
|
+
description: "Run a code review workflow"
|
|
63
|
+
};
|
|
64
|
+
const input = args && typeof args === "object" ? args : {};
|
|
65
|
+
const prompt = typeof input.prompt === "string" && input.prompt.trim()
|
|
66
|
+
? input.prompt
|
|
67
|
+
: "Review the current repository for correctness risks.";
|
|
68
|
+
return await agent(prompt, { label: "code-review" });`,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'batch',
|
|
72
|
+
script: `export const meta = {
|
|
73
|
+
name: "batch",
|
|
74
|
+
description: "Run multiple prompts in parallel"
|
|
75
|
+
};
|
|
76
|
+
const input = args && typeof args === "object" ? args : {};
|
|
77
|
+
const prompts = Array.isArray(input.prompts) ? input.prompts : [];
|
|
78
|
+
return await parallel(prompts.map((prompt, index) => () => agent(
|
|
79
|
+
prompt == null ? "" : "" + prompt,
|
|
80
|
+
{ label: "batch-" + (index + 1) }
|
|
81
|
+
)));`,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
export class WorkflowTaskRegistry {
|
|
85
|
+
options;
|
|
86
|
+
tasks = new Map();
|
|
87
|
+
permissionRequests = new Map();
|
|
88
|
+
permissionRecords;
|
|
89
|
+
closed = false;
|
|
90
|
+
stateDir;
|
|
91
|
+
agentStallTimeoutMs;
|
|
92
|
+
agentStallRetryLimit;
|
|
93
|
+
constructor(options) {
|
|
94
|
+
this.options = options;
|
|
95
|
+
this.stateDir = options.stateDir ?? join(options.cwd ?? process.cwd(), '.ultracode-for-codex');
|
|
96
|
+
this.agentStallRetryLimit = normalizeAgentStallRetryLimit(options.agentStallRetryLimit);
|
|
97
|
+
this.agentStallTimeoutMs = normalizeAgentStallTimeoutMs(options.agentStallTimeoutMs, options.requestTimeoutMs, this.agentStallRetryLimit);
|
|
98
|
+
}
|
|
99
|
+
async launch(input) {
|
|
100
|
+
if (this.closed)
|
|
101
|
+
throw workflowInputError('Workflow runtime is closed.');
|
|
102
|
+
const resumePlan = this.prepareResumePlan(input);
|
|
103
|
+
let resolved = await this.resolveLaunchInput(resumePlan.launchInput);
|
|
104
|
+
const parsed = parseInlineWorkflowScript(resolved.script);
|
|
105
|
+
const scriptHash = workflowScriptHash(resolved.script);
|
|
106
|
+
const isolationReview = workflowRequestedIsolationModes(resolved.script);
|
|
107
|
+
resolved = await this.resolveTrustedScriptPathMetadata(resolved, parsed, scriptHash, isolationReview);
|
|
108
|
+
const permissionRequired = await this.workflowPermissionRequired(resumePlan.launchInput, resolved, parsed, scriptHash, isolationReview);
|
|
109
|
+
if (permissionRequired)
|
|
110
|
+
return permissionRequired;
|
|
111
|
+
const resumeCache = resumePlan.sourceTask
|
|
112
|
+
? await this.createResumeCache(resumePlan.sourceTask)
|
|
113
|
+
: undefined;
|
|
114
|
+
const taskId = `task_${randomUUID()}`;
|
|
115
|
+
const runId = `run_${randomUUID()}`;
|
|
116
|
+
let scriptPath = resolved.scriptPath ?? this.workflowScriptPath(parsed.meta.name, runId);
|
|
117
|
+
if (!resolved.scriptPath) {
|
|
118
|
+
scriptPath = await this.persistInlineWorkflowScript(scriptPath, resolved.script);
|
|
119
|
+
await this.writeWorkflowScriptMetadata(scriptPath, resolved, parsed, scriptHash);
|
|
120
|
+
}
|
|
121
|
+
const transcriptDir = join(this.stateDir, 'subagents', 'workflows', runId);
|
|
122
|
+
const retryInput = { ...resolved, scriptPath };
|
|
123
|
+
const startedAt = Date.now();
|
|
124
|
+
const journalArgs = journalJsonValueOrInputError(resumePlan.launchInput.args ?? null, 'workflow args');
|
|
125
|
+
let journal;
|
|
126
|
+
try {
|
|
127
|
+
journal = await WorkflowJournalWriter.create({
|
|
128
|
+
transcriptDir,
|
|
129
|
+
taskId,
|
|
130
|
+
runId,
|
|
131
|
+
durability: this.options.journalDurability,
|
|
132
|
+
});
|
|
133
|
+
await journal.append({
|
|
134
|
+
kind: 'workflow.run.started',
|
|
135
|
+
workflowName: parsed.meta.name,
|
|
136
|
+
workflowSource: resolved.workflowSource,
|
|
137
|
+
...(resolved.workflowSourcePath ? { workflowSourcePath: resolved.workflowSourcePath } : {}),
|
|
138
|
+
scriptPath,
|
|
139
|
+
scriptHash,
|
|
140
|
+
args: journalArgs,
|
|
141
|
+
runtime: {
|
|
142
|
+
schemaVersion: 1,
|
|
143
|
+
cwd: this.options.cwd ?? process.cwd(),
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
if (!resolved.scriptPath) {
|
|
147
|
+
await this.recordWorkflowSourceAllow(resolved, parsed, scriptHash, isolationReview);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
await cleanupWorkflowJournalTranscriptDir(transcriptDir).catch(() => undefined);
|
|
152
|
+
if (!resolved.scriptPath) {
|
|
153
|
+
await rm(scriptPath, { force: true }).catch(() => undefined);
|
|
154
|
+
await rm(workflowScriptMetadataPath(scriptPath), { force: true }).catch(() => undefined);
|
|
155
|
+
}
|
|
156
|
+
throw workflowJournalRequestError(err);
|
|
157
|
+
}
|
|
158
|
+
const task = {
|
|
159
|
+
taskId,
|
|
160
|
+
runId,
|
|
161
|
+
workflowName: parsed.meta.name,
|
|
162
|
+
status: 'running',
|
|
163
|
+
taskType: 'local_workflow',
|
|
164
|
+
transcriptDir,
|
|
165
|
+
scriptPath,
|
|
166
|
+
workflowSource: resolved.workflowSource,
|
|
167
|
+
...(resolved.workflowSourcePath ? { workflowSourcePath: resolved.workflowSourcePath } : {}),
|
|
168
|
+
scriptHash,
|
|
169
|
+
isolationReview,
|
|
170
|
+
startedAt,
|
|
171
|
+
journal,
|
|
172
|
+
retryInput,
|
|
173
|
+
events: [],
|
|
174
|
+
waiters: [],
|
|
175
|
+
terminalEmitted: false,
|
|
176
|
+
};
|
|
177
|
+
this.tasks.set(taskId, task);
|
|
178
|
+
this.emit(task, {
|
|
179
|
+
type: 'workflow.started',
|
|
180
|
+
taskId,
|
|
181
|
+
runId,
|
|
182
|
+
workflowName: task.workflowName,
|
|
183
|
+
scriptPath,
|
|
184
|
+
workflowSource: task.workflowSource,
|
|
185
|
+
...(task.workflowSourcePath ? { workflowSourcePath: task.workflowSourcePath } : {}),
|
|
186
|
+
scriptHash,
|
|
187
|
+
});
|
|
188
|
+
task.runPromise = this.runTask(task, parsed, retryInput, resumeCache);
|
|
189
|
+
task.runPromise.catch(() => undefined);
|
|
190
|
+
return {
|
|
191
|
+
status: 'async_launched',
|
|
192
|
+
taskId,
|
|
193
|
+
taskType: 'local_workflow',
|
|
194
|
+
workflowName: task.workflowName,
|
|
195
|
+
runId,
|
|
196
|
+
summary: parsed.meta.description,
|
|
197
|
+
transcriptDir,
|
|
198
|
+
scriptPath,
|
|
199
|
+
workflowSource: task.workflowSource,
|
|
200
|
+
...(task.workflowSourcePath ? { workflowSourcePath: task.workflowSourcePath } : {}),
|
|
201
|
+
scriptHash,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
prepareResumePlan(input) {
|
|
205
|
+
if (!Object.prototype.hasOwnProperty.call(input, 'resumeFromRunId')) {
|
|
206
|
+
return { launchInput: input };
|
|
207
|
+
}
|
|
208
|
+
const resumeFromRunId = normalizeResumeFromRunId(input.resumeFromRunId);
|
|
209
|
+
const sourceTask = this.workflowTaskByRunId(resumeFromRunId);
|
|
210
|
+
if (!sourceTask)
|
|
211
|
+
throw workflowInputError(`Unknown workflow run for resume: ${resumeFromRunId}`);
|
|
212
|
+
if (sourceTask.status === 'running')
|
|
213
|
+
throw workflowResumeRunningError(resumeFromRunId);
|
|
214
|
+
const inheritedArgs = !Object.prototype.hasOwnProperty.call(input, 'args') && sourceTask.retryInput.args !== undefined
|
|
215
|
+
? { args: sourceTask.retryInput.args }
|
|
216
|
+
: {};
|
|
217
|
+
if (workflowLaunchHasSourceSelector(input)) {
|
|
218
|
+
return {
|
|
219
|
+
sourceTask,
|
|
220
|
+
launchInput: {
|
|
221
|
+
...inheritedArgs,
|
|
222
|
+
...input,
|
|
223
|
+
resumeFromRunId,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
sourceTask,
|
|
229
|
+
launchInput: {
|
|
230
|
+
...sourceTask.retryInput,
|
|
231
|
+
...inheritedArgs,
|
|
232
|
+
...(Object.prototype.hasOwnProperty.call(input, 'args') ? { args: input.args } : {}),
|
|
233
|
+
...(input.toolName ? { toolName: input.toolName } : {}),
|
|
234
|
+
resumeFromRunId,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
workflowTaskByRunId(runId) {
|
|
239
|
+
for (const task of this.tasks.values()) {
|
|
240
|
+
if (task.runId === runId)
|
|
241
|
+
return task;
|
|
242
|
+
}
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
async createResumeCache(sourceTask) {
|
|
246
|
+
let entries;
|
|
247
|
+
try {
|
|
248
|
+
entries = (await readWorkflowJournal(workflowJournalPath(sourceTask.transcriptDir))).entries;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
throw workflowInputError(`Workflow run cannot be used as a resume source: ${sourceTask.runId}`);
|
|
252
|
+
}
|
|
253
|
+
const completedByCallKey = new Map();
|
|
254
|
+
for (const entry of entries) {
|
|
255
|
+
if (entry.kind === 'workflow.agent.completed')
|
|
256
|
+
completedByCallKey.set(entry.agentCallKey, entry);
|
|
257
|
+
}
|
|
258
|
+
const cacheEntries = [];
|
|
259
|
+
for (const entry of entries) {
|
|
260
|
+
if (entry.kind !== 'workflow.agent.started')
|
|
261
|
+
continue;
|
|
262
|
+
const completed = completedByCallKey.get(entry.agentCallKey);
|
|
263
|
+
if (!completed)
|
|
264
|
+
break;
|
|
265
|
+
cacheEntries.push({
|
|
266
|
+
agentCallKey: entry.agentCallKey,
|
|
267
|
+
result: completed.result,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
entries: cacheEntries,
|
|
272
|
+
nextIndex: 0,
|
|
273
|
+
prefixOpen: true,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
async resolveLaunchInput(input) {
|
|
277
|
+
const normalized = normalizeLaunchInput(input);
|
|
278
|
+
if (normalized.scriptPath) {
|
|
279
|
+
const resolved = await this.readRuntimeWorkflowScript(normalized.scriptPath);
|
|
280
|
+
return {
|
|
281
|
+
...normalized,
|
|
282
|
+
script: resolved.script,
|
|
283
|
+
scriptPath: resolved.scriptPath,
|
|
284
|
+
workflowSource: 'script_path',
|
|
285
|
+
workflowSourcePath: resolved.scriptPath,
|
|
286
|
+
...(resolved.metadata ? { scriptMetadata: resolved.metadata } : {}),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (normalized.name) {
|
|
290
|
+
return {
|
|
291
|
+
...normalized,
|
|
292
|
+
...await this.resolveNamedWorkflow(normalized.name),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
...normalized,
|
|
297
|
+
script: normalized.script,
|
|
298
|
+
workflowSource: 'inline',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
async workflowPermissionRequired(input, resolved, parsed, scriptHash, requestedIsolation) {
|
|
302
|
+
const permissionSource = resolved.workflowSource;
|
|
303
|
+
if (!WORKFLOW_PERMISSION_REQUIRED_SOURCES.has(permissionSource))
|
|
304
|
+
return null;
|
|
305
|
+
const permissionKey = workflowPermissionKey(permissionSource, resolved.workflowSourcePath, parsed.meta.name, scriptHash);
|
|
306
|
+
const existing = await this.workflowPermissionRecord(permissionKey);
|
|
307
|
+
if (existing?.decision === 'allow'
|
|
308
|
+
&& workflowPermissionRecordMatchesCurrentReview(existing, requestedIsolation)) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
if (existing?.decision === 'deny') {
|
|
312
|
+
throw workflowPermissionDeniedError(parsed.meta.name, permissionSource, scriptHash);
|
|
313
|
+
}
|
|
314
|
+
const permissionRequestId = workflowPermissionRequestId(permissionKey);
|
|
315
|
+
const review = {
|
|
316
|
+
permissionRequestId,
|
|
317
|
+
reviewVersion: WORKFLOW_PERMISSION_REVIEW_VERSION,
|
|
318
|
+
workflowName: parsed.meta.name,
|
|
319
|
+
...(parsed.meta.description ? { summary: parsed.meta.description } : {}),
|
|
320
|
+
workflowSource: permissionSource,
|
|
321
|
+
...(resolved.workflowSourcePath ? { workflowSourcePath: resolved.workflowSourcePath } : {}),
|
|
322
|
+
scriptHash,
|
|
323
|
+
phases: parsed.meta.phases?.map((phase) => phase.title) ?? [],
|
|
324
|
+
requestedIsolationModes: requestedIsolation.modes,
|
|
325
|
+
dynamicIsolation: requestedIsolation.dynamic,
|
|
326
|
+
riskSummary: workflowPermissionRiskSummary(permissionSource, requestedIsolation),
|
|
327
|
+
scriptPreview: preview(resolved.script, 1600),
|
|
328
|
+
};
|
|
329
|
+
this.permissionRequests.set(permissionRequestId, {
|
|
330
|
+
permissionRequestId,
|
|
331
|
+
permissionKey,
|
|
332
|
+
input,
|
|
333
|
+
review,
|
|
334
|
+
});
|
|
335
|
+
return {
|
|
336
|
+
status: 'permission_required',
|
|
337
|
+
taskType: 'local_workflow',
|
|
338
|
+
workflowName: parsed.meta.name,
|
|
339
|
+
...(parsed.meta.description ? { summary: parsed.meta.description } : {}),
|
|
340
|
+
workflowSource: permissionSource,
|
|
341
|
+
...(resolved.workflowSourcePath ? { workflowSourcePath: resolved.workflowSourcePath } : {}),
|
|
342
|
+
scriptHash,
|
|
343
|
+
permissionRequestId,
|
|
344
|
+
review,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
async resolveTrustedScriptPathMetadata(resolved, parsed, scriptHash, isolationReview) {
|
|
348
|
+
if (!resolved.scriptPath || !resolved.scriptMetadata)
|
|
349
|
+
return resolved;
|
|
350
|
+
const metadata = resolved.scriptMetadata;
|
|
351
|
+
if (metadata.scriptHash !== scriptHash || metadata.workflowName !== parsed.meta.name)
|
|
352
|
+
return resolved;
|
|
353
|
+
const permissionKey = workflowPermissionKey(metadata.workflowSource, metadata.workflowSourcePath, parsed.meta.name, scriptHash);
|
|
354
|
+
if (metadata.permissionKey !== permissionKey)
|
|
355
|
+
return resolved;
|
|
356
|
+
const record = await this.workflowPermissionRecord(permissionKey);
|
|
357
|
+
if (!workflowPermissionRecordMatchesMetadata(record, metadata, parsed.meta.name, scriptHash, isolationReview))
|
|
358
|
+
return resolved;
|
|
359
|
+
const trusted = {
|
|
360
|
+
...resolved,
|
|
361
|
+
workflowSource: metadata.workflowSource,
|
|
362
|
+
scriptMetadata: metadata,
|
|
363
|
+
};
|
|
364
|
+
if (metadata.workflowSourcePath) {
|
|
365
|
+
return { ...trusted, workflowSourcePath: metadata.workflowSourcePath };
|
|
366
|
+
}
|
|
367
|
+
const { workflowSourcePath: _ignored, ...withoutSourcePath } = trusted;
|
|
368
|
+
return withoutSourcePath;
|
|
369
|
+
}
|
|
370
|
+
getPermissionRequest(permissionRequestId) {
|
|
371
|
+
return this.permissionRequests.get(permissionRequestId)?.review ?? null;
|
|
372
|
+
}
|
|
373
|
+
async approvePermissionRequest(permissionRequestId) {
|
|
374
|
+
const request = this.consumePendingWorkflowPermission(permissionRequestId);
|
|
375
|
+
await this.recordWorkflowPermission(request, 'allow');
|
|
376
|
+
return this.launch(request.input);
|
|
377
|
+
}
|
|
378
|
+
async denyPermissionRequest(permissionRequestId) {
|
|
379
|
+
const request = this.consumePendingWorkflowPermission(permissionRequestId);
|
|
380
|
+
await this.recordWorkflowPermission(request, 'deny');
|
|
381
|
+
return {
|
|
382
|
+
status: 'permission_denied',
|
|
383
|
+
taskType: 'local_workflow',
|
|
384
|
+
workflowName: request.review.workflowName,
|
|
385
|
+
workflowSource: request.review.workflowSource,
|
|
386
|
+
...(request.review.workflowSourcePath ? { workflowSourcePath: request.review.workflowSourcePath } : {}),
|
|
387
|
+
scriptHash: request.review.scriptHash,
|
|
388
|
+
permissionRequestId,
|
|
389
|
+
reason: 'workflow_permission_denied',
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
consumePendingWorkflowPermission(permissionRequestId) {
|
|
393
|
+
const request = this.permissionRequests.get(permissionRequestId);
|
|
394
|
+
if (!request)
|
|
395
|
+
throw workflowInputError(`Unknown workflow permission request: ${permissionRequestId}`);
|
|
396
|
+
this.deletePendingWorkflowPermissions(request.permissionKey);
|
|
397
|
+
return request;
|
|
398
|
+
}
|
|
399
|
+
deletePendingWorkflowPermissions(permissionKey) {
|
|
400
|
+
for (const [requestId, request] of this.permissionRequests.entries()) {
|
|
401
|
+
if (request.permissionKey === permissionKey)
|
|
402
|
+
this.permissionRequests.delete(requestId);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
async workflowPermissionRecord(permissionKey) {
|
|
406
|
+
return (await this.workflowPermissionRecords()).get(permissionKey);
|
|
407
|
+
}
|
|
408
|
+
async recordWorkflowPermission(request, decision) {
|
|
409
|
+
await this.recordWorkflowPermissionRecord({
|
|
410
|
+
permissionKey: request.permissionKey,
|
|
411
|
+
decision,
|
|
412
|
+
...workflowPermissionReviewRecordFields(request.review),
|
|
413
|
+
workflowName: request.review.workflowName,
|
|
414
|
+
workflowSource: request.review.workflowSource,
|
|
415
|
+
...(request.review.workflowSourcePath ? { workflowSourcePath: request.review.workflowSourcePath } : {}),
|
|
416
|
+
scriptHash: request.review.scriptHash,
|
|
417
|
+
decidedAt: new Date().toISOString(),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
async recordWorkflowSourceAllow(resolved, parsed, scriptHash, isolationReview) {
|
|
421
|
+
const permissionKey = workflowPermissionKey(resolved.workflowSource, resolved.workflowSourcePath, parsed.meta.name, scriptHash);
|
|
422
|
+
const existing = await this.workflowPermissionRecord(permissionKey);
|
|
423
|
+
if (existing?.decision === 'allow'
|
|
424
|
+
&& workflowPermissionRecordMatchesCurrentReview(existing, isolationReview)) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
await this.recordWorkflowPermissionRecord({
|
|
428
|
+
permissionKey,
|
|
429
|
+
decision: 'allow',
|
|
430
|
+
...workflowIsolationReviewRecordFields(isolationReview),
|
|
431
|
+
workflowName: parsed.meta.name,
|
|
432
|
+
workflowSource: resolved.workflowSource,
|
|
433
|
+
...(resolved.workflowSourcePath ? { workflowSourcePath: resolved.workflowSourcePath } : {}),
|
|
434
|
+
scriptHash,
|
|
435
|
+
decidedAt: new Date().toISOString(),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
async recordWorkflowPermissionRecord(record) {
|
|
439
|
+
const records = await this.workflowPermissionRecords();
|
|
440
|
+
records.set(record.permissionKey, record);
|
|
441
|
+
await this.writeWorkflowPermissionRecords(records);
|
|
442
|
+
}
|
|
443
|
+
async workflowPermissionRecords() {
|
|
444
|
+
if (this.permissionRecords)
|
|
445
|
+
return this.permissionRecords;
|
|
446
|
+
const records = new Map();
|
|
447
|
+
try {
|
|
448
|
+
const raw = JSON.parse(await readFile(this.workflowPermissionStorePath(), 'utf8'));
|
|
449
|
+
const store = asRecord(raw);
|
|
450
|
+
const decisions = store?.decisions;
|
|
451
|
+
if (Array.isArray(decisions)) {
|
|
452
|
+
for (const item of decisions) {
|
|
453
|
+
const record = workflowPermissionRecordFromUnknown(item);
|
|
454
|
+
if (record)
|
|
455
|
+
records.set(record.permissionKey, record);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
catch (err) {
|
|
460
|
+
const code = err.code;
|
|
461
|
+
if (code !== 'ENOENT')
|
|
462
|
+
throw workflowInputError('Workflow permission store cannot be read.');
|
|
463
|
+
}
|
|
464
|
+
this.permissionRecords = records;
|
|
465
|
+
return records;
|
|
466
|
+
}
|
|
467
|
+
async writeWorkflowPermissionRecords(records) {
|
|
468
|
+
await ensureWorkflowStateDirectory(join(this.stateDir, 'workflows'));
|
|
469
|
+
const store = {
|
|
470
|
+
version: WORKFLOW_PERMISSION_STORE_VERSION,
|
|
471
|
+
decisions: [...records.values()].sort((left, right) => left.permissionKey.localeCompare(right.permissionKey)),
|
|
472
|
+
};
|
|
473
|
+
await writeWorkflowStateFile(this.workflowPermissionStorePath(), `${JSON.stringify(store, null, 2)}\n`);
|
|
474
|
+
}
|
|
475
|
+
workflowPermissionStorePath() {
|
|
476
|
+
return join(this.stateDir, 'workflows', 'permissions.json');
|
|
477
|
+
}
|
|
478
|
+
async resolveNamedWorkflow(name) {
|
|
479
|
+
const available = new Set();
|
|
480
|
+
const project = await this.findNamedWorkflowInDirs(name, 'project', PROJECT_WORKFLOW_DIRS.map((dir) => join(this.options.cwd ?? process.cwd(), dir)), available);
|
|
481
|
+
if (project)
|
|
482
|
+
return project;
|
|
483
|
+
const user = await this.findNamedWorkflowInDirs(name, 'user', this.userWorkflowDirs(), available);
|
|
484
|
+
if (user)
|
|
485
|
+
return user;
|
|
486
|
+
for (const plugin of this.options.pluginWorkflows ?? []) {
|
|
487
|
+
const pluginName = plugin.pluginName.trim();
|
|
488
|
+
if (!pluginName)
|
|
489
|
+
continue;
|
|
490
|
+
const pluginWorkflow = await this.findNamedWorkflowInDirs(name, 'plugin', pluginWorkflowDirs(plugin), available, pluginName);
|
|
491
|
+
if (pluginWorkflow)
|
|
492
|
+
return pluginWorkflow;
|
|
493
|
+
}
|
|
494
|
+
const builtIn = this.findBuiltinWorkflow(name, available);
|
|
495
|
+
if (builtIn)
|
|
496
|
+
return builtIn;
|
|
497
|
+
throw namedWorkflowNotFoundError(name, available);
|
|
498
|
+
}
|
|
499
|
+
userWorkflowDirs() {
|
|
500
|
+
if (this.options.userWorkflowDirs)
|
|
501
|
+
return this.options.userWorkflowDirs;
|
|
502
|
+
const codexHome = process.env.CODEX_HOME?.trim()
|
|
503
|
+
? resolve(process.env.CODEX_HOME)
|
|
504
|
+
: join(homedir(), '.codex');
|
|
505
|
+
return [
|
|
506
|
+
join(codexHome, 'workflows'),
|
|
507
|
+
];
|
|
508
|
+
}
|
|
509
|
+
async findNamedWorkflowInDirs(requestedName, workflowSource, dirs, available, prefix) {
|
|
510
|
+
for (const dir of dirs) {
|
|
511
|
+
const files = await workflowScriptFiles(dir);
|
|
512
|
+
const ordered = [
|
|
513
|
+
...files.filter((file) => prefixedWorkflowName(workflowFileName(file), prefix) === requestedName),
|
|
514
|
+
...files.filter((file) => prefixedWorkflowName(workflowFileName(file), prefix) !== requestedName),
|
|
515
|
+
];
|
|
516
|
+
for (const file of ordered) {
|
|
517
|
+
const scriptPath = join(dir, file);
|
|
518
|
+
const fileName = prefixedWorkflowName(workflowFileName(file), prefix);
|
|
519
|
+
let script;
|
|
520
|
+
let canonicalPath;
|
|
521
|
+
let parsed;
|
|
522
|
+
try {
|
|
523
|
+
canonicalPath = await realpath(scriptPath);
|
|
524
|
+
script = await readFile(canonicalPath, 'utf8');
|
|
525
|
+
parsed = parseInlineWorkflowScript(script);
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
if (fileName === requestedName)
|
|
529
|
+
throw err;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const metaName = prefixedWorkflowName(parsed.meta.name, prefix);
|
|
533
|
+
available.add(metaName);
|
|
534
|
+
if (fileName !== metaName)
|
|
535
|
+
available.add(fileName);
|
|
536
|
+
if (fileName === requestedName || metaName === requestedName) {
|
|
537
|
+
return {
|
|
538
|
+
name: requestedName,
|
|
539
|
+
script,
|
|
540
|
+
workflowSource,
|
|
541
|
+
workflowSourcePath: canonicalPath,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
findBuiltinWorkflow(requestedName, available) {
|
|
549
|
+
for (const workflow of this.options.builtinWorkflows ?? DEFAULT_BUILTIN_WORKFLOWS) {
|
|
550
|
+
const name = workflow.name.trim();
|
|
551
|
+
if (!name)
|
|
552
|
+
continue;
|
|
553
|
+
available.add(name);
|
|
554
|
+
if (name !== requestedName)
|
|
555
|
+
continue;
|
|
556
|
+
return {
|
|
557
|
+
name,
|
|
558
|
+
script: workflow.script,
|
|
559
|
+
workflowSource: 'built_in',
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
workflowScriptsDir() {
|
|
565
|
+
return join(this.stateDir, 'workflows', 'scripts');
|
|
566
|
+
}
|
|
567
|
+
workflowScriptPath(workflowName, runId) {
|
|
568
|
+
return join(this.workflowScriptsDir(), `${workflowScriptSlug(workflowName)}-${runId}.js`);
|
|
569
|
+
}
|
|
570
|
+
async persistInlineWorkflowScript(scriptPath, script) {
|
|
571
|
+
await ensureWorkflowStateDirectory(this.workflowScriptsDir());
|
|
572
|
+
await writeWorkflowStateFile(scriptPath, script, { flag: 'wx' });
|
|
573
|
+
return await realpath(scriptPath);
|
|
574
|
+
}
|
|
575
|
+
async writeWorkflowScriptMetadata(scriptPath, resolved, parsed, scriptHash) {
|
|
576
|
+
const metadata = {
|
|
577
|
+
version: 1,
|
|
578
|
+
workflowName: parsed.meta.name,
|
|
579
|
+
workflowSource: resolved.workflowSource,
|
|
580
|
+
...(resolved.workflowSourcePath ? { workflowSourcePath: resolved.workflowSourcePath } : {}),
|
|
581
|
+
scriptHash,
|
|
582
|
+
permissionKey: workflowPermissionKey(resolved.workflowSource, resolved.workflowSourcePath, parsed.meta.name, scriptHash),
|
|
583
|
+
};
|
|
584
|
+
await writeWorkflowStateFile(workflowScriptMetadataPath(scriptPath), `${JSON.stringify(metadata, null, 2)}\n`, { flag: 'wx' });
|
|
585
|
+
}
|
|
586
|
+
async readRuntimeWorkflowScript(scriptPath) {
|
|
587
|
+
const scriptsDir = this.workflowScriptsDir();
|
|
588
|
+
await ensureWorkflowStateDirectory(scriptsDir);
|
|
589
|
+
const root = await realpath(scriptsDir);
|
|
590
|
+
const requested = isAbsolute(scriptPath)
|
|
591
|
+
? resolve(scriptPath)
|
|
592
|
+
: resolve(this.options.cwd ?? process.cwd(), scriptPath);
|
|
593
|
+
let canonicalScriptPath;
|
|
594
|
+
try {
|
|
595
|
+
canonicalScriptPath = await realpath(requested);
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
throw workflowInputError(`Workflow scriptPath not found: ${scriptPath}`);
|
|
599
|
+
}
|
|
600
|
+
if (!pathInsideOrEqual(root, canonicalScriptPath)) {
|
|
601
|
+
throw workflowInputError('scriptPath must point to a runtime-owned workflow script.');
|
|
602
|
+
}
|
|
603
|
+
try {
|
|
604
|
+
const script = await readFile(canonicalScriptPath, 'utf8');
|
|
605
|
+
const metadata = await this.readWorkflowScriptMetadata(canonicalScriptPath);
|
|
606
|
+
const currentScriptHash = workflowScriptHash(script);
|
|
607
|
+
return {
|
|
608
|
+
script,
|
|
609
|
+
scriptPath: canonicalScriptPath,
|
|
610
|
+
...(metadata.metadata?.scriptHash === currentScriptHash ? metadata : {}),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
throw workflowInputError(`Workflow scriptPath cannot be read: ${scriptPath}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async readWorkflowScriptMetadata(scriptPath) {
|
|
618
|
+
try {
|
|
619
|
+
const metadata = workflowScriptMetadataFromUnknown(JSON.parse(await readFile(workflowScriptMetadataPath(scriptPath), 'utf8')));
|
|
620
|
+
return metadata ? { metadata } : {};
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
const code = err.code;
|
|
624
|
+
if (code === 'ENOENT')
|
|
625
|
+
return {};
|
|
626
|
+
return {};
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
get(taskId) {
|
|
630
|
+
const task = this.tasks.get(taskId);
|
|
631
|
+
if (!task)
|
|
632
|
+
return null;
|
|
633
|
+
return workflowTaskSnapshot(task);
|
|
634
|
+
}
|
|
635
|
+
async cancel(taskId) {
|
|
636
|
+
const task = this.tasks.get(taskId);
|
|
637
|
+
if (!task)
|
|
638
|
+
throw workflowInputError(`Unknown workflow task: ${taskId}`);
|
|
639
|
+
if (task.status === 'running') {
|
|
640
|
+
task.abortRequested = true;
|
|
641
|
+
task.abortFailure = { message: 'Workflow cancelled.', reason: 'workflow_aborted' };
|
|
642
|
+
task.controller?.abort();
|
|
643
|
+
if (task.runPromise) {
|
|
644
|
+
await task.runPromise;
|
|
645
|
+
return workflowTaskSnapshot(task);
|
|
646
|
+
}
|
|
647
|
+
return await this.failTask(task, 'Workflow cancelled.', 'workflow_aborted');
|
|
648
|
+
}
|
|
649
|
+
return workflowTaskSnapshot(task);
|
|
650
|
+
}
|
|
651
|
+
async retry(taskId) {
|
|
652
|
+
const task = this.tasks.get(taskId);
|
|
653
|
+
if (!task)
|
|
654
|
+
throw workflowInputError(`Unknown workflow task: ${taskId}`);
|
|
655
|
+
if (task.status === 'running') {
|
|
656
|
+
throw new UltracodeRequestError('Running workflows cannot be retried; cancel or wait for a terminal state first.', 409, 'invalid_request_error', WORKFLOW_INPUT_PARAM, 'workflow_input_invalid');
|
|
657
|
+
}
|
|
658
|
+
return this.launch(task.retryInput);
|
|
659
|
+
}
|
|
660
|
+
async *streamEvents(taskId, signal) {
|
|
661
|
+
const task = this.tasks.get(taskId);
|
|
662
|
+
if (!task)
|
|
663
|
+
throw workflowInputError(`Unknown workflow task: ${taskId}`);
|
|
664
|
+
let index = 0;
|
|
665
|
+
while (true) {
|
|
666
|
+
while (index < task.events.length) {
|
|
667
|
+
if (signal?.aborted)
|
|
668
|
+
return;
|
|
669
|
+
yield task.events[index];
|
|
670
|
+
index += 1;
|
|
671
|
+
}
|
|
672
|
+
if (task.status !== 'running')
|
|
673
|
+
return;
|
|
674
|
+
if (!await waitForWorkflowTaskEvent(task, signal))
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
async close() {
|
|
679
|
+
this.closed = true;
|
|
680
|
+
for (const task of this.tasks.values()) {
|
|
681
|
+
if (task.status === 'running') {
|
|
682
|
+
task.abortRequested = true;
|
|
683
|
+
task.abortFailure = { message: 'Workflow runtime closed.', reason: 'runtime_closed' };
|
|
684
|
+
task.controller?.abort();
|
|
685
|
+
if (task.runPromise) {
|
|
686
|
+
await task.runPromise;
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
await this.failTask(task, 'Workflow runtime closed.', 'runtime_closed');
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
await this.options.backend.close();
|
|
694
|
+
}
|
|
695
|
+
async runTask(task, parsed, input, resumeCache) {
|
|
696
|
+
const controller = new AbortController();
|
|
697
|
+
const ctx = {
|
|
698
|
+
task,
|
|
699
|
+
parsed,
|
|
700
|
+
input,
|
|
701
|
+
isolationReview: task.isolationReview,
|
|
702
|
+
...(resumeCache ? { resumeCache } : {}),
|
|
703
|
+
startedAt: task.startedAt,
|
|
704
|
+
model: this.options.backend.model,
|
|
705
|
+
inputTokens: 0,
|
|
706
|
+
outputTokens: 0,
|
|
707
|
+
agentCount: 0,
|
|
708
|
+
tokens: 0,
|
|
709
|
+
toolCalls: 0,
|
|
710
|
+
controller,
|
|
711
|
+
timers: new Map(),
|
|
712
|
+
asyncFinalizers: new Set(),
|
|
713
|
+
nextTimerId: 1,
|
|
714
|
+
previousAgentCallKey: WORKFLOW_JOURNAL_GENESIS_AGENT_CALL_KEY,
|
|
715
|
+
};
|
|
716
|
+
task.controller = controller;
|
|
717
|
+
if (task.abortRequested)
|
|
718
|
+
controller.abort();
|
|
719
|
+
if (task.status !== 'running') {
|
|
720
|
+
controller.abort();
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const workflowTimer = setTimeout(() => {
|
|
724
|
+
controller.abort();
|
|
725
|
+
}, this.options.requestTimeoutMs);
|
|
726
|
+
try {
|
|
727
|
+
if (controller.signal.aborted)
|
|
728
|
+
throw workflowInputError('Workflow is aborted.');
|
|
729
|
+
const result = await executeInlineWorkflow(parsed, this.createVmGlobals(ctx), controller.signal);
|
|
730
|
+
await this.drainWorkflowFinalizers(ctx);
|
|
731
|
+
if (controller.signal.aborted || task.status !== 'running')
|
|
732
|
+
return;
|
|
733
|
+
const journalResult = journalJsonValueOrInputError(result, 'workflow result');
|
|
734
|
+
const resultPath = join(this.stateDir, 'workflows', `${task.runId}.result.json`);
|
|
735
|
+
await ensureWorkflowStateDirectory(join(this.stateDir, 'workflows'));
|
|
736
|
+
await writeWorkflowStateFile(resultPath, `${JSON.stringify({
|
|
737
|
+
taskId: task.taskId,
|
|
738
|
+
runId: task.runId,
|
|
739
|
+
workflowName: task.workflowName,
|
|
740
|
+
workflowSource: task.workflowSource,
|
|
741
|
+
...(task.workflowSourcePath ? { workflowSourcePath: task.workflowSourcePath } : {}),
|
|
742
|
+
scriptHash: task.scriptHash,
|
|
743
|
+
result: journalResult,
|
|
744
|
+
}, null, 2)}\n`);
|
|
745
|
+
const completedSnapshot = await this.completeTask(ctx, journalResult, {
|
|
746
|
+
type: 'workflow.completed',
|
|
747
|
+
taskId: task.taskId,
|
|
748
|
+
runId: task.runId,
|
|
749
|
+
resultPath,
|
|
750
|
+
agentCount: ctx.agentCount,
|
|
751
|
+
tokens: ctx.tokens,
|
|
752
|
+
toolCalls: ctx.toolCalls,
|
|
753
|
+
durationMs: Date.now() - ctx.startedAt,
|
|
754
|
+
});
|
|
755
|
+
if (completedSnapshot.status !== 'completed') {
|
|
756
|
+
await rm(resultPath, { force: true }).catch(() => undefined);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
catch (err) {
|
|
760
|
+
const abortFailure = controller.signal.aborted
|
|
761
|
+
? task.abortFailure ?? { message: 'Workflow timed out or was aborted.', reason: 'workflow_aborted' }
|
|
762
|
+
: null;
|
|
763
|
+
await this.drainWorkflowFinalizers(ctx);
|
|
764
|
+
await this.failTask(task, abortFailure ? abortFailure.message : workflowErrorMessage(err), abortFailure ? abortFailure.reason : workflowFailureReason(err));
|
|
765
|
+
}
|
|
766
|
+
finally {
|
|
767
|
+
clearTimeout(workflowTimer);
|
|
768
|
+
for (const timer of ctx.timers.values())
|
|
769
|
+
clearTimeout(timer);
|
|
770
|
+
ctx.timers.clear();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
createVmGlobals(ctx) {
|
|
774
|
+
const log = (message) => {
|
|
775
|
+
if (ctx.controller.signal.aborted || ctx.task.status !== 'running')
|
|
776
|
+
return;
|
|
777
|
+
this.emit(ctx.task, {
|
|
778
|
+
type: 'workflow.log',
|
|
779
|
+
taskId: ctx.task.taskId,
|
|
780
|
+
runId: ctx.task.runId,
|
|
781
|
+
message: String(message),
|
|
782
|
+
});
|
|
783
|
+
};
|
|
784
|
+
const workflowSetTimeout = hardenCallable((callback, delay, ...args) => {
|
|
785
|
+
if (ctx.controller.signal.aborted || ctx.task.status !== 'running')
|
|
786
|
+
return 0;
|
|
787
|
+
if (typeof callback !== 'function')
|
|
788
|
+
throw workflowInputError('setTimeout callback must be a function.');
|
|
789
|
+
const ms = typeof delay === 'number' && Number.isFinite(delay) && delay >= 0 ? delay : 0;
|
|
790
|
+
const timerId = ctx.nextTimerId;
|
|
791
|
+
ctx.nextTimerId += 1;
|
|
792
|
+
const handle = setTimeout(() => {
|
|
793
|
+
ctx.timers.delete(timerId);
|
|
794
|
+
if (!ctx.controller.signal.aborted && ctx.task.status === 'running') {
|
|
795
|
+
try {
|
|
796
|
+
const returned = callback(...args);
|
|
797
|
+
Promise.resolve(returned).catch((err) => {
|
|
798
|
+
void this.failTaskFromCallback(ctx, err);
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
catch (err) {
|
|
802
|
+
void this.failTaskFromCallback(ctx, err);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}, ms);
|
|
806
|
+
ctx.timers.set(timerId, handle);
|
|
807
|
+
return timerId;
|
|
808
|
+
});
|
|
809
|
+
const workflowClearTimeout = hardenCallable((handle) => {
|
|
810
|
+
if (typeof handle !== 'number')
|
|
811
|
+
return;
|
|
812
|
+
const timer = ctx.timers.get(handle);
|
|
813
|
+
if (!timer)
|
|
814
|
+
return;
|
|
815
|
+
clearTimeout(timer);
|
|
816
|
+
ctx.timers.delete(handle);
|
|
817
|
+
});
|
|
818
|
+
const host = Object.create(null);
|
|
819
|
+
host.trackPromise = hardenCallable((value) => this.trackWorkflowPromise(ctx, value));
|
|
820
|
+
host.agent = hardenCallable((prompt, options) => this.runAgent(ctx, prompt, options));
|
|
821
|
+
host.parallel = hardenCallable((items) => {
|
|
822
|
+
return this.trackWorkflowPromise(ctx, this.parallel(ctx, items));
|
|
823
|
+
});
|
|
824
|
+
host.pipeline = hardenCallable((items, ...stages) => {
|
|
825
|
+
return this.trackWorkflowPromise(ctx, this.pipeline(ctx, items, stages));
|
|
826
|
+
});
|
|
827
|
+
host.phase = hardenCallable((title) => this.phase(ctx, title));
|
|
828
|
+
host.log = hardenCallable(log);
|
|
829
|
+
host.consoleLog = hardenCallable((...values) => {
|
|
830
|
+
log(values.map((value) => String(value)).join(' '));
|
|
831
|
+
});
|
|
832
|
+
host.workflow = hardenCallable(() => {
|
|
833
|
+
throw workflowInputError('Nested workflow calls are not supported by this runtime yet.');
|
|
834
|
+
});
|
|
835
|
+
host.setTimeout = workflowSetTimeout;
|
|
836
|
+
host.clearTimeout = workflowClearTimeout;
|
|
837
|
+
return {
|
|
838
|
+
argsLiteral: vmDataLiteral(ctx.input.args, 'args'),
|
|
839
|
+
budgetLiteral: vmDataLiteral({
|
|
840
|
+
maxAgentCalls: MAX_AGENT_CALLS,
|
|
841
|
+
maxParallelism: MAX_PARALLELISM,
|
|
842
|
+
agentStallTimeoutMs: this.agentStallTimeoutMs,
|
|
843
|
+
agentStallRetryLimit: this.agentStallRetryLimit,
|
|
844
|
+
}, 'budget'),
|
|
845
|
+
host: Object.freeze(host),
|
|
846
|
+
setVmValueProjector: (projector) => {
|
|
847
|
+
ctx.toVmValue = projector;
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
runAgent(ctx, prompt, options) {
|
|
852
|
+
let resolveFinalizer;
|
|
853
|
+
const finalizer = new Promise((resolve) => {
|
|
854
|
+
resolveFinalizer = resolve;
|
|
855
|
+
});
|
|
856
|
+
ctx.asyncFinalizers.add(finalizer);
|
|
857
|
+
const inner = this.runAgentInner(ctx, prompt, options);
|
|
858
|
+
inner.catch(() => undefined);
|
|
859
|
+
const operation = inner.finally(() => {
|
|
860
|
+
ctx.asyncFinalizers.delete(finalizer);
|
|
861
|
+
resolveFinalizer();
|
|
862
|
+
});
|
|
863
|
+
operation.catch(() => undefined);
|
|
864
|
+
return this.trackWorkflowPromise(ctx, operation);
|
|
865
|
+
}
|
|
866
|
+
async runAgentInner(ctx, prompt, options) {
|
|
867
|
+
if (typeof prompt !== 'string' || prompt.trim() === '') {
|
|
868
|
+
throw workflowInputError('agent(prompt) requires a non-empty string prompt.');
|
|
869
|
+
}
|
|
870
|
+
if (ctx.controller.signal.aborted || ctx.task.status !== 'running') {
|
|
871
|
+
throw workflowInputError('Workflow is aborted.');
|
|
872
|
+
}
|
|
873
|
+
if (options?.model !== undefined) {
|
|
874
|
+
throw workflowInputError('agent model override is not supported by this runtime yet.');
|
|
875
|
+
}
|
|
876
|
+
if (ctx.agentCount >= MAX_AGENT_CALLS) {
|
|
877
|
+
throw workflowInputError(`Workflow agent call cap exceeded (${MAX_AGENT_CALLS}).`);
|
|
878
|
+
}
|
|
879
|
+
const schema = normalizeStructuredOutputSchema(options?.schema);
|
|
880
|
+
const isolation = normalizeAgentIsolation(options?.isolation);
|
|
881
|
+
if (isolation && !workflowIsolationReviewAllowsMode(ctx.isolationReview, isolation)) {
|
|
882
|
+
throw workflowInputError(`agent ${isolation} isolation was not covered by the current workflow permission review.`);
|
|
883
|
+
}
|
|
884
|
+
const agentIndex = ctx.agentCount;
|
|
885
|
+
ctx.agentCount += 1;
|
|
886
|
+
const agentId = `agent_${agentIndex + 1}`;
|
|
887
|
+
const semanticOpts = workflowAgentSemanticOpts({
|
|
888
|
+
model: ctx.model,
|
|
889
|
+
effort: 'xhigh',
|
|
890
|
+
schema,
|
|
891
|
+
isolation,
|
|
892
|
+
});
|
|
893
|
+
const previousAgentCallKey = ctx.previousAgentCallKey;
|
|
894
|
+
const agentCallKey = computeWorkflowAgentCallKey({
|
|
895
|
+
previousAgentCallKey,
|
|
896
|
+
prompt,
|
|
897
|
+
semanticOpts,
|
|
898
|
+
});
|
|
899
|
+
ctx.previousAgentCallKey = agentCallKey;
|
|
900
|
+
const label = options?.label ?? preview(prompt, 48);
|
|
901
|
+
const phase = options?.phase ?? ctx.currentPhase;
|
|
902
|
+
try {
|
|
903
|
+
await ctx.task.journal.append({
|
|
904
|
+
kind: 'workflow.agent.started',
|
|
905
|
+
agentIndex,
|
|
906
|
+
agentId,
|
|
907
|
+
previousAgentCallKey,
|
|
908
|
+
agentCallKey,
|
|
909
|
+
prompt,
|
|
910
|
+
semanticOpts,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
catch (err) {
|
|
914
|
+
ctx.controller.abort();
|
|
915
|
+
await this.failTask(ctx.task, 'Workflow journal write failed before agent start.', WORKFLOW_JOURNAL_WRITE_FAILED_REASON);
|
|
916
|
+
throw workflowJournalRuntimeError(err);
|
|
917
|
+
}
|
|
918
|
+
this.emit(ctx.task, {
|
|
919
|
+
type: 'workflow.agent.started',
|
|
920
|
+
taskId: ctx.task.taskId,
|
|
921
|
+
runId: ctx.task.runId,
|
|
922
|
+
agentIndex,
|
|
923
|
+
agentId,
|
|
924
|
+
label,
|
|
925
|
+
phase,
|
|
926
|
+
promptPreview: preview(prompt, 160),
|
|
927
|
+
});
|
|
928
|
+
const cached = takeResumeCacheHit(ctx.resumeCache, agentCallKey);
|
|
929
|
+
if (cached) {
|
|
930
|
+
try {
|
|
931
|
+
await ctx.task.journal.append({
|
|
932
|
+
kind: 'workflow.agent.completed',
|
|
933
|
+
agentIndex,
|
|
934
|
+
agentId,
|
|
935
|
+
agentCallKey,
|
|
936
|
+
result: cached.result,
|
|
937
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
938
|
+
toolCalls: 0,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
catch (err) {
|
|
942
|
+
ctx.controller.abort();
|
|
943
|
+
await this.failTask(ctx.task, 'Workflow journal write failed after cached agent completion.', WORKFLOW_JOURNAL_WRITE_FAILED_REASON);
|
|
944
|
+
throw workflowJournalRuntimeError(err);
|
|
945
|
+
}
|
|
946
|
+
this.emit(ctx.task, {
|
|
947
|
+
type: 'workflow.agent.completed',
|
|
948
|
+
taskId: ctx.task.taskId,
|
|
949
|
+
runId: ctx.task.runId,
|
|
950
|
+
agentIndex,
|
|
951
|
+
agentId,
|
|
952
|
+
label,
|
|
953
|
+
phase,
|
|
954
|
+
tokens: 0,
|
|
955
|
+
toolCalls: 0,
|
|
956
|
+
resultPreview: previewValue(cached.result, 160),
|
|
957
|
+
cached: true,
|
|
958
|
+
});
|
|
959
|
+
return cached.result;
|
|
960
|
+
}
|
|
961
|
+
const preservedWorktrees = [];
|
|
962
|
+
const recordWorktreeFinalization = (finalization) => {
|
|
963
|
+
if (!finalization.preserved || !finalization.preservedWorktree)
|
|
964
|
+
return;
|
|
965
|
+
if (preservedWorktrees.some((item) => item.path === finalization.preservedWorktree?.path))
|
|
966
|
+
return;
|
|
967
|
+
preservedWorktrees.push(finalization.preservedWorktree);
|
|
968
|
+
};
|
|
969
|
+
try {
|
|
970
|
+
const attempt = await this.runAgentWithStallRetries(ctx, {
|
|
971
|
+
agentId,
|
|
972
|
+
prompt,
|
|
973
|
+
schema,
|
|
974
|
+
isolation,
|
|
975
|
+
model: ctx.model,
|
|
976
|
+
onWorktreeFinalized: recordWorktreeFinalization,
|
|
977
|
+
});
|
|
978
|
+
const result = attempt.result;
|
|
979
|
+
if (attempt.worktreeFinalization)
|
|
980
|
+
recordWorktreeFinalization(attempt.worktreeFinalization);
|
|
981
|
+
if (ctx.controller.signal.aborted || ctx.task.status !== 'running') {
|
|
982
|
+
throw workflowInputError('Workflow is aborted.');
|
|
983
|
+
}
|
|
984
|
+
const agentResult = schema
|
|
985
|
+
? structuredAgentResult(result, schema)
|
|
986
|
+
: agentResultText(result);
|
|
987
|
+
const journalResult = journalJsonValueOrInputError(agentResult, 'agent result');
|
|
988
|
+
const usage = workflowUsage(result.usage);
|
|
989
|
+
const toolCalls = result.toolCalls.length;
|
|
990
|
+
try {
|
|
991
|
+
await ctx.task.journal.append({
|
|
992
|
+
kind: 'workflow.agent.completed',
|
|
993
|
+
agentIndex,
|
|
994
|
+
agentId,
|
|
995
|
+
agentCallKey,
|
|
996
|
+
result: journalResult,
|
|
997
|
+
usage,
|
|
998
|
+
toolCalls,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
catch (err) {
|
|
1002
|
+
ctx.controller.abort();
|
|
1003
|
+
await this.failTask(ctx.task, 'Workflow journal write failed after agent completion.', WORKFLOW_JOURNAL_WRITE_FAILED_REASON);
|
|
1004
|
+
throw workflowJournalRuntimeError(err);
|
|
1005
|
+
}
|
|
1006
|
+
ctx.inputTokens += usage.inputTokens;
|
|
1007
|
+
ctx.outputTokens += usage.outputTokens;
|
|
1008
|
+
ctx.tokens += usage.totalTokens;
|
|
1009
|
+
ctx.toolCalls += toolCalls;
|
|
1010
|
+
this.emit(ctx.task, {
|
|
1011
|
+
type: 'workflow.agent.completed',
|
|
1012
|
+
taskId: ctx.task.taskId,
|
|
1013
|
+
runId: ctx.task.runId,
|
|
1014
|
+
agentIndex,
|
|
1015
|
+
agentId,
|
|
1016
|
+
label,
|
|
1017
|
+
phase,
|
|
1018
|
+
tokens: usage.totalTokens,
|
|
1019
|
+
toolCalls,
|
|
1020
|
+
resultPreview: previewValue(journalResult, 160),
|
|
1021
|
+
...preservedWorktreeEventProjection(preservedWorktrees),
|
|
1022
|
+
});
|
|
1023
|
+
return journalResult;
|
|
1024
|
+
}
|
|
1025
|
+
catch (err) {
|
|
1026
|
+
if (isWorkflowJournalError(err))
|
|
1027
|
+
throw err;
|
|
1028
|
+
if (ctx.task.status !== 'running')
|
|
1029
|
+
throw err;
|
|
1030
|
+
const error = workflowErrorMessage(err);
|
|
1031
|
+
try {
|
|
1032
|
+
await ctx.task.journal.append({
|
|
1033
|
+
kind: 'workflow.agent.failed',
|
|
1034
|
+
agentIndex,
|
|
1035
|
+
agentId,
|
|
1036
|
+
agentCallKey,
|
|
1037
|
+
reason: ctx.controller.signal.aborted
|
|
1038
|
+
? ctx.task.abortFailure?.reason ?? 'workflow_aborted'
|
|
1039
|
+
: workflowFailureReason(err),
|
|
1040
|
+
message: error,
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
catch (journalErr) {
|
|
1044
|
+
ctx.controller.abort();
|
|
1045
|
+
await this.failTask(ctx.task, 'Workflow journal write failed after agent failure.', WORKFLOW_JOURNAL_WRITE_FAILED_REASON);
|
|
1046
|
+
throw workflowJournalRuntimeError(journalErr);
|
|
1047
|
+
}
|
|
1048
|
+
this.emit(ctx.task, {
|
|
1049
|
+
type: 'workflow.agent.failed',
|
|
1050
|
+
taskId: ctx.task.taskId,
|
|
1051
|
+
runId: ctx.task.runId,
|
|
1052
|
+
agentIndex,
|
|
1053
|
+
agentId,
|
|
1054
|
+
label,
|
|
1055
|
+
phase,
|
|
1056
|
+
error,
|
|
1057
|
+
...preservedWorktreeEventProjection(preservedWorktrees),
|
|
1058
|
+
});
|
|
1059
|
+
if (ctx.controller.signal.aborted)
|
|
1060
|
+
return null;
|
|
1061
|
+
throw err;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
async createAgentWorktree(ctx, agentId, attemptIndex = 0) {
|
|
1065
|
+
const cwd = this.options.cwd ?? process.cwd();
|
|
1066
|
+
let gitRoot;
|
|
1067
|
+
try {
|
|
1068
|
+
gitRoot = await gitOutput(cwd, ['rev-parse', '--show-toplevel']);
|
|
1069
|
+
await gitOutput(gitRoot, ['rev-parse', '--verify', 'HEAD']);
|
|
1070
|
+
}
|
|
1071
|
+
catch (err) {
|
|
1072
|
+
throw workflowInputError(`worktree isolation requires a git repository with at least one commit: ${workflowErrorMessage(err)}`);
|
|
1073
|
+
}
|
|
1074
|
+
const worktreeRoot = join(dirname(gitRoot), '.ultracode-for-codex-worktrees', `${workflowScriptSlug(basename(gitRoot))}-${shortHash(gitRoot)}`, ctx.task.runId);
|
|
1075
|
+
await ensureWorkflowStateDirectory(worktreeRoot);
|
|
1076
|
+
const worktreePath = join(worktreeRoot, attemptIndex === 0 ? agentId : `${agentId}-attempt-${attemptIndex + 1}`);
|
|
1077
|
+
try {
|
|
1078
|
+
await gitOutput(gitRoot, ['worktree', 'add', '--detach', worktreePath, 'HEAD']);
|
|
1079
|
+
return {
|
|
1080
|
+
gitRoot,
|
|
1081
|
+
path: await realpath(worktreePath),
|
|
1082
|
+
attemptIndex,
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
catch (err) {
|
|
1086
|
+
await rm(worktreePath, { recursive: true, force: true }).catch(() => undefined);
|
|
1087
|
+
throw workflowInputError(`worktree isolation could not create an isolated worktree: ${workflowErrorMessage(err)}`);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
async finalizeAgentWorktree(worktree) {
|
|
1091
|
+
let status;
|
|
1092
|
+
try {
|
|
1093
|
+
status = await gitOutput(worktree.path, ['status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching']);
|
|
1094
|
+
}
|
|
1095
|
+
catch {
|
|
1096
|
+
return {
|
|
1097
|
+
preserved: true,
|
|
1098
|
+
preservedWorktree: preservedWorktree(worktree, 'status_unavailable'),
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
if (status.trim()) {
|
|
1102
|
+
return {
|
|
1103
|
+
preserved: true,
|
|
1104
|
+
preservedWorktree: preservedWorktree(worktree, 'changed'),
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
await removeCleanGitWorktree(worktree);
|
|
1109
|
+
}
|
|
1110
|
+
catch {
|
|
1111
|
+
return {
|
|
1112
|
+
preserved: true,
|
|
1113
|
+
preservedWorktree: preservedWorktree(worktree, 'cleanup_failed'),
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
return { preserved: false };
|
|
1117
|
+
}
|
|
1118
|
+
async runAgentWithStallRetries(ctx, input) {
|
|
1119
|
+
for (let retryIndex = 0; retryIndex <= this.agentStallRetryLimit; retryIndex += 1) {
|
|
1120
|
+
if (ctx.controller.signal.aborted || ctx.task.status !== 'running') {
|
|
1121
|
+
throw workflowInputError('Workflow is aborted.');
|
|
1122
|
+
}
|
|
1123
|
+
const worktree = input.isolation === 'worktree'
|
|
1124
|
+
? await this.createAgentWorktree(ctx, input.agentId, retryIndex)
|
|
1125
|
+
: undefined;
|
|
1126
|
+
try {
|
|
1127
|
+
const result = await this.runAgentAttempt(ctx, agentRequest({
|
|
1128
|
+
model: input.model,
|
|
1129
|
+
prompt: input.prompt,
|
|
1130
|
+
schema: input.schema,
|
|
1131
|
+
worktreePath: worktree?.path,
|
|
1132
|
+
}));
|
|
1133
|
+
const worktreeFinalization = worktree
|
|
1134
|
+
? await this.finalizeAgentWorktree(worktree)
|
|
1135
|
+
: undefined;
|
|
1136
|
+
if (worktreeFinalization)
|
|
1137
|
+
input.onWorktreeFinalized(worktreeFinalization);
|
|
1138
|
+
return {
|
|
1139
|
+
result,
|
|
1140
|
+
...(worktreeFinalization ? { worktreeFinalization } : {}),
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
catch (err) {
|
|
1144
|
+
if (worktree) {
|
|
1145
|
+
const worktreeFinalization = isWorkflowAgentStalledError(err) || ctx.controller.signal.aborted
|
|
1146
|
+
? {
|
|
1147
|
+
preserved: true,
|
|
1148
|
+
preservedWorktree: preservedWorktree(worktree, ctx.controller.signal.aborted ? 'aborted' : 'stalled'),
|
|
1149
|
+
}
|
|
1150
|
+
: await this.finalizeAgentWorktree(worktree);
|
|
1151
|
+
input.onWorktreeFinalized(worktreeFinalization);
|
|
1152
|
+
}
|
|
1153
|
+
if (isWorkflowAgentStalledError(err)
|
|
1154
|
+
&& retryIndex < this.agentStallRetryLimit
|
|
1155
|
+
&& !ctx.controller.signal.aborted
|
|
1156
|
+
&& ctx.task.status === 'running') {
|
|
1157
|
+
this.emit(ctx.task, {
|
|
1158
|
+
type: 'workflow.log',
|
|
1159
|
+
taskId: ctx.task.taskId,
|
|
1160
|
+
runId: ctx.task.runId,
|
|
1161
|
+
message: `${input.agentId} stalled; retrying (${retryIndex + 1}/${this.agentStallRetryLimit}).`,
|
|
1162
|
+
});
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
throw err;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
throw workflowAgentStalledError(`Workflow agent stalled after ${this.agentStallRetryLimit + 1} attempts.`);
|
|
1169
|
+
}
|
|
1170
|
+
async gitStatusArgsExcludingRuntimeState(gitRoot) {
|
|
1171
|
+
const args = ['status', '--porcelain=v1', '--untracked-files=all', '--', '.'];
|
|
1172
|
+
const canonicalGitRoot = await realpath(gitRoot).catch(() => resolve(gitRoot));
|
|
1173
|
+
const canonicalStateDir = await realpath(this.stateDir).catch(() => resolve(this.stateDir));
|
|
1174
|
+
const relativeStateDir = relative(canonicalGitRoot, canonicalStateDir);
|
|
1175
|
+
if (relativeStateDir && !relativeStateDir.startsWith('..') && !isAbsolute(relativeStateDir)) {
|
|
1176
|
+
args.push(`:(exclude)${relativeStateDir}`);
|
|
1177
|
+
args.push(`:(exclude)${relativeStateDir}/**`);
|
|
1178
|
+
}
|
|
1179
|
+
return args;
|
|
1180
|
+
}
|
|
1181
|
+
async runAgentAttempt(ctx, request) {
|
|
1182
|
+
const attemptController = new AbortController();
|
|
1183
|
+
let timedOut = false;
|
|
1184
|
+
const abortFromWorkflow = () => {
|
|
1185
|
+
attemptController.abort();
|
|
1186
|
+
};
|
|
1187
|
+
if (ctx.controller.signal.aborted || ctx.task.status !== 'running') {
|
|
1188
|
+
throw workflowInputError('Workflow is aborted.');
|
|
1189
|
+
}
|
|
1190
|
+
ctx.controller.signal.addEventListener('abort', abortFromWorkflow, { once: true });
|
|
1191
|
+
const timer = setTimeout(() => {
|
|
1192
|
+
timedOut = true;
|
|
1193
|
+
attemptController.abort();
|
|
1194
|
+
}, this.agentStallTimeoutMs);
|
|
1195
|
+
try {
|
|
1196
|
+
const generated = this.options.backend.generate(request, attemptController.signal).then((result) => ({ type: 'result', result }), (err) => ({ type: 'error', error: err }));
|
|
1197
|
+
const aborted = new Promise((resolve) => {
|
|
1198
|
+
attemptController.signal.addEventListener('abort', () => {
|
|
1199
|
+
resolve({ type: 'aborted' });
|
|
1200
|
+
}, { once: true });
|
|
1201
|
+
});
|
|
1202
|
+
const outcome = await Promise.race([generated, aborted]);
|
|
1203
|
+
if (timedOut) {
|
|
1204
|
+
throw workflowAgentStalledError(`Workflow agent stalled after ${this.agentStallTimeoutMs} ms.`);
|
|
1205
|
+
}
|
|
1206
|
+
if (outcome.type === 'result')
|
|
1207
|
+
return outcome.result;
|
|
1208
|
+
if (outcome.type === 'aborted') {
|
|
1209
|
+
throw workflowInputError('Workflow is aborted.');
|
|
1210
|
+
}
|
|
1211
|
+
if (ctx.controller.signal.aborted || ctx.task.status !== 'running') {
|
|
1212
|
+
throw workflowInputError('Workflow is aborted.');
|
|
1213
|
+
}
|
|
1214
|
+
throw outcome.error;
|
|
1215
|
+
}
|
|
1216
|
+
finally {
|
|
1217
|
+
clearTimeout(timer);
|
|
1218
|
+
ctx.controller.signal.removeEventListener('abort', abortFromWorkflow);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
async parallel(ctx, items) {
|
|
1222
|
+
if (!Array.isArray(items))
|
|
1223
|
+
throw workflowInputError('parallel() requires an array.');
|
|
1224
|
+
return mapWithConcurrency(items, MAX_PARALLELISM, async (item) => {
|
|
1225
|
+
try {
|
|
1226
|
+
return typeof item === 'function' ? await item() : await item;
|
|
1227
|
+
}
|
|
1228
|
+
catch (err) {
|
|
1229
|
+
this.emit(ctx.task, {
|
|
1230
|
+
type: 'workflow.log',
|
|
1231
|
+
taskId: ctx.task.taskId,
|
|
1232
|
+
runId: ctx.task.runId,
|
|
1233
|
+
message: `parallel item failed: ${workflowErrorMessage(err)}`,
|
|
1234
|
+
});
|
|
1235
|
+
return null;
|
|
1236
|
+
}
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
async pipeline(ctx, items, stages) {
|
|
1240
|
+
if (!Array.isArray(items))
|
|
1241
|
+
throw workflowInputError('pipeline() requires an item array.');
|
|
1242
|
+
for (const stage of stages) {
|
|
1243
|
+
if (typeof stage !== 'function')
|
|
1244
|
+
throw workflowInputError('pipeline() stages must be functions.');
|
|
1245
|
+
}
|
|
1246
|
+
return mapWithConcurrency(items, MAX_PARALLELISM, async (item) => {
|
|
1247
|
+
let current = item;
|
|
1248
|
+
for (const stage of stages) {
|
|
1249
|
+
if (current === null || current === undefined)
|
|
1250
|
+
return null;
|
|
1251
|
+
try {
|
|
1252
|
+
current = await stage(current);
|
|
1253
|
+
}
|
|
1254
|
+
catch (err) {
|
|
1255
|
+
this.emit(ctx.task, {
|
|
1256
|
+
type: 'workflow.log',
|
|
1257
|
+
taskId: ctx.task.taskId,
|
|
1258
|
+
runId: ctx.task.runId,
|
|
1259
|
+
message: `pipeline stage failed: ${workflowErrorMessage(err)}`,
|
|
1260
|
+
});
|
|
1261
|
+
return null;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return current;
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
phase(ctx, title) {
|
|
1268
|
+
if (typeof title !== 'string' || title.trim() === '') {
|
|
1269
|
+
throw workflowInputError('phase() requires a non-empty string title.');
|
|
1270
|
+
}
|
|
1271
|
+
ctx.currentPhase = title;
|
|
1272
|
+
const phaseIndex = ctx.task.events
|
|
1273
|
+
.filter((event) => event.type === 'workflow.phase.started')
|
|
1274
|
+
.length;
|
|
1275
|
+
const detail = ctx.parsed.meta.phases?.find((item) => item.title === title)?.detail;
|
|
1276
|
+
this.emit(ctx.task, {
|
|
1277
|
+
type: 'workflow.phase.started',
|
|
1278
|
+
taskId: ctx.task.taskId,
|
|
1279
|
+
runId: ctx.task.runId,
|
|
1280
|
+
phaseIndex,
|
|
1281
|
+
title,
|
|
1282
|
+
...(detail ? { detail } : {}),
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
async completeTask(ctx, result, event) {
|
|
1286
|
+
return await this.finalizeTask(ctx.task, async () => {
|
|
1287
|
+
await ctx.task.journal.append({
|
|
1288
|
+
kind: 'workflow.run.completed',
|
|
1289
|
+
result,
|
|
1290
|
+
resultPath: event.resultPath,
|
|
1291
|
+
agentCount: ctx.agentCount,
|
|
1292
|
+
usage: {
|
|
1293
|
+
inputTokens: ctx.inputTokens,
|
|
1294
|
+
outputTokens: ctx.outputTokens,
|
|
1295
|
+
totalTokens: ctx.tokens,
|
|
1296
|
+
},
|
|
1297
|
+
toolCalls: ctx.toolCalls,
|
|
1298
|
+
durationMs: event.durationMs,
|
|
1299
|
+
});
|
|
1300
|
+
ctx.task.result = result;
|
|
1301
|
+
ctx.task.resultPath = event.resultPath;
|
|
1302
|
+
this.emit(ctx.task, event);
|
|
1303
|
+
ctx.task.status = 'completed';
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
async failTask(task, error, reason) {
|
|
1307
|
+
return await this.finalizeTask(task, async () => {
|
|
1308
|
+
await task.journal.append({
|
|
1309
|
+
kind: 'workflow.run.failed',
|
|
1310
|
+
reason,
|
|
1311
|
+
message: error,
|
|
1312
|
+
recovery: { retryable: true, reason },
|
|
1313
|
+
durationMs: Date.now() - task.startedAt,
|
|
1314
|
+
});
|
|
1315
|
+
task.error = error;
|
|
1316
|
+
task.failureReason = reason;
|
|
1317
|
+
this.emit(task, {
|
|
1318
|
+
type: 'workflow.failed',
|
|
1319
|
+
taskId: task.taskId,
|
|
1320
|
+
runId: task.runId,
|
|
1321
|
+
error,
|
|
1322
|
+
recovery: { retryable: true, reason },
|
|
1323
|
+
});
|
|
1324
|
+
task.status = 'failed';
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
async finalizeTask(task, action) {
|
|
1328
|
+
if (task.terminalFinalization)
|
|
1329
|
+
return await task.terminalFinalization;
|
|
1330
|
+
task.terminalFinalization = (async () => {
|
|
1331
|
+
if (task.status !== 'running' || task.terminalEmitted)
|
|
1332
|
+
return workflowTaskSnapshot(task);
|
|
1333
|
+
try {
|
|
1334
|
+
await action();
|
|
1335
|
+
}
|
|
1336
|
+
catch (err) {
|
|
1337
|
+
task.error = WORKFLOW_JOURNAL_PUBLIC_FAILURE_MESSAGE;
|
|
1338
|
+
task.failureReason = WORKFLOW_JOURNAL_WRITE_FAILED_REASON;
|
|
1339
|
+
task.status = 'failed';
|
|
1340
|
+
task.terminalEmitted = true;
|
|
1341
|
+
this.notifyTaskWaiters(task);
|
|
1342
|
+
}
|
|
1343
|
+
return workflowTaskSnapshot(task);
|
|
1344
|
+
})();
|
|
1345
|
+
return await task.terminalFinalization;
|
|
1346
|
+
}
|
|
1347
|
+
async failTaskFromCallback(ctx, err, reason = 'workflow_timer_callback_failed', excludeFinalizer) {
|
|
1348
|
+
if (ctx.controller.signal.aborted || ctx.task.status !== 'running')
|
|
1349
|
+
return;
|
|
1350
|
+
ctx.task.abortFailure = {
|
|
1351
|
+
message: workflowErrorMessage(err),
|
|
1352
|
+
reason,
|
|
1353
|
+
};
|
|
1354
|
+
ctx.controller.abort();
|
|
1355
|
+
await this.drainWorkflowFinalizers(ctx, excludeFinalizer);
|
|
1356
|
+
await this.failTask(ctx.task, ctx.task.abortFailure.message, ctx.task.abortFailure.reason);
|
|
1357
|
+
}
|
|
1358
|
+
async drainWorkflowFinalizers(ctx, exclude) {
|
|
1359
|
+
while (ctx.asyncFinalizers.size > (exclude ? 1 : 0)) {
|
|
1360
|
+
const pending = [...ctx.asyncFinalizers].filter((finalizer) => finalizer !== exclude);
|
|
1361
|
+
if (pending.length === 0)
|
|
1362
|
+
return;
|
|
1363
|
+
await Promise.allSettled(pending);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
trackWorkflowPromise(ctx, value) {
|
|
1367
|
+
const promise = Promise.resolve(value);
|
|
1368
|
+
const tracking = {
|
|
1369
|
+
handled: false,
|
|
1370
|
+
projectValue: (nextValue) => ctx.toVmValue ? ctx.toVmValue(nextValue) : nextValue,
|
|
1371
|
+
trackPromise: (nextPromise) => this.trackWorkflowPromise(ctx, nextPromise),
|
|
1372
|
+
};
|
|
1373
|
+
let finalizer;
|
|
1374
|
+
const settled = promise.then(() => undefined, async (reason) => {
|
|
1375
|
+
await sleep(0);
|
|
1376
|
+
if (!tracking.handled) {
|
|
1377
|
+
await this.failTaskFromCallback(ctx, reason, 'workflow_promise_rejected', finalizer);
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
const aborted = new Promise((resolve) => {
|
|
1381
|
+
if (ctx.controller.signal.aborted) {
|
|
1382
|
+
resolve();
|
|
1383
|
+
}
|
|
1384
|
+
else {
|
|
1385
|
+
ctx.controller.signal.addEventListener('abort', () => resolve(), { once: true });
|
|
1386
|
+
}
|
|
1387
|
+
});
|
|
1388
|
+
finalizer = Promise.race([settled, aborted]).finally(() => {
|
|
1389
|
+
ctx.asyncFinalizers.delete(finalizer);
|
|
1390
|
+
});
|
|
1391
|
+
finalizer.catch(() => undefined);
|
|
1392
|
+
ctx.asyncFinalizers.add(finalizer);
|
|
1393
|
+
return handledWorkflowPromise(promise, tracking);
|
|
1394
|
+
}
|
|
1395
|
+
emit(task, event) {
|
|
1396
|
+
if (task.terminalEmitted)
|
|
1397
|
+
return;
|
|
1398
|
+
if (isTerminalWorkflowEvent(event))
|
|
1399
|
+
task.terminalEmitted = true;
|
|
1400
|
+
task.events.push(event);
|
|
1401
|
+
this.notifyTaskWaiters(task);
|
|
1402
|
+
}
|
|
1403
|
+
notifyTaskWaiters(task) {
|
|
1404
|
+
const waiters = task.waiters.splice(0);
|
|
1405
|
+
for (const waiter of waiters)
|
|
1406
|
+
waiter();
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
function normalizeLaunchInput(input) {
|
|
1410
|
+
if (input.toolName !== undefined && !WORKFLOW_TOOL_NAMES.has(input.toolName)) {
|
|
1411
|
+
throw workflowInputError('Workflow launch tool must be Workflow or RunWorkflow.');
|
|
1412
|
+
}
|
|
1413
|
+
const resume = Object.prototype.hasOwnProperty.call(input, 'resumeFromRunId')
|
|
1414
|
+
? { resumeFromRunId: normalizeResumeFromRunId(input.resumeFromRunId) }
|
|
1415
|
+
: {};
|
|
1416
|
+
if (input.scriptPath !== undefined) {
|
|
1417
|
+
if (typeof input.scriptPath !== 'string') {
|
|
1418
|
+
throw workflowInputError('Workflow scriptPath must be a non-empty string.');
|
|
1419
|
+
}
|
|
1420
|
+
const scriptPath = input.scriptPath.trim();
|
|
1421
|
+
if (!scriptPath)
|
|
1422
|
+
throw workflowInputError('Workflow scriptPath must be a non-empty string.');
|
|
1423
|
+
return { ...input, ...resume, scriptPath };
|
|
1424
|
+
}
|
|
1425
|
+
if (input.name !== undefined) {
|
|
1426
|
+
if (typeof input.name !== 'string') {
|
|
1427
|
+
throw workflowInputError('Workflow name must be a non-empty string.');
|
|
1428
|
+
}
|
|
1429
|
+
const name = input.name.trim();
|
|
1430
|
+
if (!name)
|
|
1431
|
+
throw workflowInputError('Workflow name must be a non-empty string.');
|
|
1432
|
+
return { ...input, ...resume, name };
|
|
1433
|
+
}
|
|
1434
|
+
if (typeof input.script !== 'string' || input.script.trim() === '') {
|
|
1435
|
+
throw workflowInputError('Workflow launch requires an inline script string.');
|
|
1436
|
+
}
|
|
1437
|
+
return { ...input, ...resume, script: input.script };
|
|
1438
|
+
}
|
|
1439
|
+
function workflowLaunchHasSourceSelector(input) {
|
|
1440
|
+
return input.script !== undefined || input.name !== undefined || input.scriptPath !== undefined;
|
|
1441
|
+
}
|
|
1442
|
+
function normalizeResumeFromRunId(value) {
|
|
1443
|
+
if (typeof value !== 'string') {
|
|
1444
|
+
throw workflowInputError('resumeFromRunId must be a non-empty workflow runId string.');
|
|
1445
|
+
}
|
|
1446
|
+
const runId = value.trim();
|
|
1447
|
+
if (!runId)
|
|
1448
|
+
throw workflowInputError('resumeFromRunId must be a non-empty workflow runId string.');
|
|
1449
|
+
return runId;
|
|
1450
|
+
}
|
|
1451
|
+
function normalizeAgentIsolation(value) {
|
|
1452
|
+
if (value === undefined)
|
|
1453
|
+
return undefined;
|
|
1454
|
+
if (value === 'worktree')
|
|
1455
|
+
return 'worktree';
|
|
1456
|
+
throw workflowInputError('agent isolation must be "worktree" when provided.');
|
|
1457
|
+
}
|
|
1458
|
+
function takeResumeCacheHit(cache, agentCallKey) {
|
|
1459
|
+
if (!cache || !cache.prefixOpen)
|
|
1460
|
+
return null;
|
|
1461
|
+
const entry = cache.entries[cache.nextIndex];
|
|
1462
|
+
if (!entry || entry.agentCallKey !== agentCallKey) {
|
|
1463
|
+
cache.prefixOpen = false;
|
|
1464
|
+
return null;
|
|
1465
|
+
}
|
|
1466
|
+
cache.nextIndex += 1;
|
|
1467
|
+
return entry;
|
|
1468
|
+
}
|
|
1469
|
+
async function gitOutput(cwd, args) {
|
|
1470
|
+
try {
|
|
1471
|
+
const result = await execFileAsync('git', args, {
|
|
1472
|
+
cwd,
|
|
1473
|
+
encoding: 'utf8',
|
|
1474
|
+
maxBuffer: 1024 * 1024,
|
|
1475
|
+
});
|
|
1476
|
+
return result.stdout.trim();
|
|
1477
|
+
}
|
|
1478
|
+
catch (err) {
|
|
1479
|
+
const record = err;
|
|
1480
|
+
const stderr = typeof record.stderr === 'string' ? record.stderr.trim() : '';
|
|
1481
|
+
const message = stderr || (typeof record.message === 'string' ? record.message : String(err));
|
|
1482
|
+
throw new Error(message);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
async function removeCleanGitWorktree(worktree) {
|
|
1486
|
+
try {
|
|
1487
|
+
await gitOutput(worktree.gitRoot, ['worktree', 'remove', '--force', worktree.path]);
|
|
1488
|
+
}
|
|
1489
|
+
catch (err) {
|
|
1490
|
+
if (/No such file|not a working tree|is not a working tree/i.test(workflowErrorMessage(err))) {
|
|
1491
|
+
await rm(worktree.path, { recursive: true, force: true }).catch(() => undefined);
|
|
1492
|
+
await gitOutput(worktree.gitRoot, ['worktree', 'prune']).catch(() => undefined);
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
throw err;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
function preservedWorktree(worktree, reason) {
|
|
1499
|
+
return {
|
|
1500
|
+
path: worktree.path,
|
|
1501
|
+
attemptIndex: worktree.attemptIndex,
|
|
1502
|
+
reason,
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
function preservedWorktreeEventProjection(preservedWorktrees) {
|
|
1506
|
+
if (preservedWorktrees.length === 0)
|
|
1507
|
+
return {};
|
|
1508
|
+
const primary = preservedWorktrees.find((item) => item.reason === 'changed')
|
|
1509
|
+
?? preservedWorktrees.find((item) => item.reason === 'cleanup_failed' || item.reason === 'status_unavailable')
|
|
1510
|
+
?? preservedWorktrees[0];
|
|
1511
|
+
return {
|
|
1512
|
+
worktreePath: primary?.path,
|
|
1513
|
+
worktreePreserved: true,
|
|
1514
|
+
preservedWorktrees: [...preservedWorktrees],
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
function shortHash(value) {
|
|
1518
|
+
return createHash('sha256').update(value).digest('hex').slice(0, 12);
|
|
1519
|
+
}
|
|
1520
|
+
function workflowScriptHash(script) {
|
|
1521
|
+
return `sha256:${createHash('sha256').update(script).digest('hex')}`;
|
|
1522
|
+
}
|
|
1523
|
+
function workflowPermissionKey(workflowSource, workflowSourcePath, workflowName, scriptHash) {
|
|
1524
|
+
const sourceRef = workflowSourcePath ?? workflowName;
|
|
1525
|
+
return `${workflowSource}\0${sourceRef}\0${workflowName}\0${scriptHash}`;
|
|
1526
|
+
}
|
|
1527
|
+
function workflowPermissionRequestId(permissionKey) {
|
|
1528
|
+
const permissionKeyHash = createHash('sha256').update(permissionKey).digest('hex').slice(0, 12);
|
|
1529
|
+
return `perm_${permissionKeyHash}_${randomUUID().replaceAll('-', '').slice(0, 16)}`;
|
|
1530
|
+
}
|
|
1531
|
+
function workflowIsolationReviewRecordFields(isolationReview) {
|
|
1532
|
+
return {
|
|
1533
|
+
reviewVersion: WORKFLOW_PERMISSION_REVIEW_VERSION,
|
|
1534
|
+
requestedIsolationModes: [...isolationReview.modes].sort((left, right) => left.localeCompare(right)),
|
|
1535
|
+
dynamicIsolation: isolationReview.dynamic,
|
|
1536
|
+
isolationReviewFingerprint: workflowIsolationReviewFingerprint(isolationReview),
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
function workflowPermissionReviewRecordFields(review) {
|
|
1540
|
+
return workflowIsolationReviewRecordFields({
|
|
1541
|
+
modes: review.requestedIsolationModes,
|
|
1542
|
+
dynamic: review.dynamicIsolation,
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
function workflowIsolationReviewFingerprint(isolationReview) {
|
|
1546
|
+
const canonical = JSON.stringify({
|
|
1547
|
+
dynamic: isolationReview.dynamic,
|
|
1548
|
+
modes: [...isolationReview.modes].sort((left, right) => left.localeCompare(right)),
|
|
1549
|
+
});
|
|
1550
|
+
return `sha256:${createHash('sha256').update(canonical).digest('hex')}`;
|
|
1551
|
+
}
|
|
1552
|
+
function workflowPermissionRecordMatchesCurrentReview(record, isolationReview) {
|
|
1553
|
+
const expected = workflowIsolationReviewRecordFields(isolationReview);
|
|
1554
|
+
return record.reviewVersion === expected.reviewVersion
|
|
1555
|
+
&& record.dynamicIsolation === expected.dynamicIsolation
|
|
1556
|
+
&& record.isolationReviewFingerprint === expected.isolationReviewFingerprint
|
|
1557
|
+
&& arraysEqual(record.requestedIsolationModes ?? [], expected.requestedIsolationModes ?? []);
|
|
1558
|
+
}
|
|
1559
|
+
function arraysEqual(left, right) {
|
|
1560
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1561
|
+
}
|
|
1562
|
+
function workflowIsolationReviewAllowsMode(isolationReview, mode) {
|
|
1563
|
+
return isolationReview.dynamic || isolationReview.modes.includes(mode);
|
|
1564
|
+
}
|
|
1565
|
+
function workflowPermissionRiskSummary(workflowSource, requestedIsolation) {
|
|
1566
|
+
const sourceSummary = (() => {
|
|
1567
|
+
if (workflowSource === 'project')
|
|
1568
|
+
return 'Project workflow source requires approval before execution.';
|
|
1569
|
+
if (workflowSource === 'user')
|
|
1570
|
+
return 'User workflow source requires approval before execution.';
|
|
1571
|
+
if (workflowSource === 'plugin')
|
|
1572
|
+
return 'Plugin workflow source requires approval before execution.';
|
|
1573
|
+
if (workflowSource === 'script_path')
|
|
1574
|
+
return 'Edited runtime workflow scriptPath requires approval before execution.';
|
|
1575
|
+
return 'Workflow source is trusted by default.';
|
|
1576
|
+
})();
|
|
1577
|
+
const isolationSummaries = [];
|
|
1578
|
+
if (requestedIsolation.modes.includes('worktree')) {
|
|
1579
|
+
isolationSummaries.push('Requests worktree isolation, allowing subagents to write inside isolated git worktrees that may be preserved for review.');
|
|
1580
|
+
}
|
|
1581
|
+
if (requestedIsolation.dynamic) {
|
|
1582
|
+
isolationSummaries.push('Contains dynamic agent isolation options; review as a possible worktree write request.');
|
|
1583
|
+
}
|
|
1584
|
+
const unknownModes = requestedIsolation.modes.filter((mode) => mode !== 'worktree');
|
|
1585
|
+
if (unknownModes.length > 0) {
|
|
1586
|
+
isolationSummaries.push(`Requests unsupported agent isolation mode(s): ${unknownModes.join(', ')}.`);
|
|
1587
|
+
}
|
|
1588
|
+
return [sourceSummary, ...isolationSummaries].join(' ');
|
|
1589
|
+
}
|
|
1590
|
+
function workflowRequestedIsolationModes(script) {
|
|
1591
|
+
const modes = new Set();
|
|
1592
|
+
let dynamic = workflowAgentCallsHaveDynamicOptions(script);
|
|
1593
|
+
for (let index = 0; index < script.length; index += 1) {
|
|
1594
|
+
const char = script[index] ?? '';
|
|
1595
|
+
const next = script[index + 1] ?? '';
|
|
1596
|
+
if (char === '/' && next === '/') {
|
|
1597
|
+
index = skipLineComment(script, index + 2);
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
if (char === '/' && next === '*') {
|
|
1601
|
+
index = skipBlockComment(script, index + 2);
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
if (char === '.' && next === '.' && script[index + 2] === '.') {
|
|
1605
|
+
dynamic = true;
|
|
1606
|
+
index += 2;
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
if (char === '`') {
|
|
1610
|
+
const template = readTemplateLiteralReview(script, index);
|
|
1611
|
+
if (template) {
|
|
1612
|
+
dynamic = template.dynamic || dynamic;
|
|
1613
|
+
for (const mode of template.modes)
|
|
1614
|
+
modes.add(mode);
|
|
1615
|
+
index = template.end;
|
|
1616
|
+
}
|
|
1617
|
+
else {
|
|
1618
|
+
dynamic = true;
|
|
1619
|
+
}
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
if (char === '[') {
|
|
1623
|
+
const computed = readComputedPropertyKey(script, index);
|
|
1624
|
+
if (computed) {
|
|
1625
|
+
if (computed.key === 'isolation') {
|
|
1626
|
+
dynamic = readIsolationModeValue(script, computed.afterColon + 1, modes) || dynamic;
|
|
1627
|
+
}
|
|
1628
|
+
else if (computed.hasColon) {
|
|
1629
|
+
dynamic = true;
|
|
1630
|
+
}
|
|
1631
|
+
index = computed.end;
|
|
1632
|
+
}
|
|
1633
|
+
else {
|
|
1634
|
+
dynamic = true;
|
|
1635
|
+
}
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
if (char === '.'
|
|
1639
|
+
&& script.startsWith('isolation', index + 1)
|
|
1640
|
+
&& !isIdentifierChar(script[index + 1 + 'isolation'.length] ?? '')) {
|
|
1641
|
+
dynamic = true;
|
|
1642
|
+
index += 'isolation'.length;
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
1645
|
+
if (char === '"' || char === "'") {
|
|
1646
|
+
const key = readStringLiteral(script, index, char);
|
|
1647
|
+
if (!key)
|
|
1648
|
+
continue;
|
|
1649
|
+
const colon = firstNonCodeWhitespace(script, key.end + 1);
|
|
1650
|
+
if (key.value === 'isolation' && script[colon] === ':') {
|
|
1651
|
+
dynamic = readIsolationModeValue(script, colon + 1, modes) || dynamic;
|
|
1652
|
+
}
|
|
1653
|
+
index = key.end;
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
if (script.startsWith('isolation', index)
|
|
1657
|
+
&& !isIdentifierChar(script[index - 1] ?? '')
|
|
1658
|
+
&& !isIdentifierChar(script[index + 'isolation'.length] ?? '')) {
|
|
1659
|
+
const colon = firstNonCodeWhitespace(script, index + 'isolation'.length);
|
|
1660
|
+
if (script[colon] === ':') {
|
|
1661
|
+
dynamic = readIsolationModeValue(script, colon + 1, modes) || dynamic;
|
|
1662
|
+
}
|
|
1663
|
+
else {
|
|
1664
|
+
dynamic = true;
|
|
1665
|
+
}
|
|
1666
|
+
index += 'isolation'.length - 1;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
return {
|
|
1670
|
+
modes: [...modes].sort((left, right) => left.localeCompare(right)),
|
|
1671
|
+
dynamic,
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
function workflowAgentCallsHaveDynamicOptions(script) {
|
|
1675
|
+
for (let index = 0; index < script.length; index += 1) {
|
|
1676
|
+
const char = script[index] ?? '';
|
|
1677
|
+
const next = script[index + 1] ?? '';
|
|
1678
|
+
if (char === '/' && next === '/') {
|
|
1679
|
+
index = skipLineComment(script, index + 2);
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
if (char === '/' && next === '*') {
|
|
1683
|
+
index = skipBlockComment(script, index + 2);
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
if (char === '"' || char === "'") {
|
|
1687
|
+
const literal = readStringLiteral(script, index, char);
|
|
1688
|
+
if (!literal)
|
|
1689
|
+
continue;
|
|
1690
|
+
index = literal.end;
|
|
1691
|
+
continue;
|
|
1692
|
+
}
|
|
1693
|
+
if (char === '`') {
|
|
1694
|
+
const template = readTemplateLiteralReview(script, index);
|
|
1695
|
+
if (!template)
|
|
1696
|
+
return true;
|
|
1697
|
+
if (template.dynamic)
|
|
1698
|
+
return true;
|
|
1699
|
+
index = template.end;
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
if (!script.startsWith('agent', index) || isIdentifierChar(script[index - 1] ?? '') || isIdentifierChar(script[index + 5] ?? '')) {
|
|
1703
|
+
continue;
|
|
1704
|
+
}
|
|
1705
|
+
const openParen = firstNonWhitespace(script, index + 5);
|
|
1706
|
+
if (script[openParen] !== '(')
|
|
1707
|
+
continue;
|
|
1708
|
+
const args = splitTopLevelCallArguments(script, openParen);
|
|
1709
|
+
if (!args)
|
|
1710
|
+
return true;
|
|
1711
|
+
if (args.args.length < 2) {
|
|
1712
|
+
index = args.end;
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
const optionsArg = args.args[1]?.trim() ?? '';
|
|
1716
|
+
if (!optionsArg.startsWith('{') || !optionsArg.endsWith('}'))
|
|
1717
|
+
return true;
|
|
1718
|
+
if (optionsArg.includes('...'))
|
|
1719
|
+
return true;
|
|
1720
|
+
index = args.end;
|
|
1721
|
+
}
|
|
1722
|
+
return false;
|
|
1723
|
+
}
|
|
1724
|
+
function readIsolationModeValue(script, start, modes) {
|
|
1725
|
+
const valueStart = firstNonCodeWhitespace(script, start);
|
|
1726
|
+
const quote = script[valueStart];
|
|
1727
|
+
if (quote === '"' || quote === "'") {
|
|
1728
|
+
const value = readStringLiteral(script, valueStart, quote);
|
|
1729
|
+
if (value?.value)
|
|
1730
|
+
modes.add(value.value);
|
|
1731
|
+
return false;
|
|
1732
|
+
}
|
|
1733
|
+
if (quote === '`') {
|
|
1734
|
+
const value = readSimpleTemplateLiteral(script, valueStart);
|
|
1735
|
+
if (value?.value) {
|
|
1736
|
+
modes.add(value.value);
|
|
1737
|
+
return false;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
return true;
|
|
1741
|
+
}
|
|
1742
|
+
function readComputedPropertyKey(script, start) {
|
|
1743
|
+
const closeBracket = findMatchingBracket(script, start);
|
|
1744
|
+
if (closeBracket === -1)
|
|
1745
|
+
return null;
|
|
1746
|
+
const keyStart = firstNonCodeWhitespace(script, start + 1);
|
|
1747
|
+
const quote = script[keyStart];
|
|
1748
|
+
let key;
|
|
1749
|
+
if (quote === '"' || quote === "'") {
|
|
1750
|
+
const literal = readStringLiteral(script, keyStart, quote);
|
|
1751
|
+
if (!literal)
|
|
1752
|
+
return null;
|
|
1753
|
+
if (firstNonCodeWhitespace(script, literal.end + 1) === closeBracket)
|
|
1754
|
+
key = literal.value;
|
|
1755
|
+
}
|
|
1756
|
+
const afterBracket = firstNonCodeWhitespace(script, closeBracket + 1);
|
|
1757
|
+
const hasColon = script[afterBracket] === ':';
|
|
1758
|
+
return {
|
|
1759
|
+
...(key !== undefined ? { key } : {}),
|
|
1760
|
+
end: closeBracket,
|
|
1761
|
+
hasColon,
|
|
1762
|
+
afterColon: afterBracket,
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
function findMatchingBracket(script, start) {
|
|
1766
|
+
let depth = 0;
|
|
1767
|
+
let quote = null;
|
|
1768
|
+
let escaped = false;
|
|
1769
|
+
let lineComment = false;
|
|
1770
|
+
let blockComment = false;
|
|
1771
|
+
for (let index = start; index < script.length; index += 1) {
|
|
1772
|
+
const char = script[index] ?? '';
|
|
1773
|
+
const next = script[index + 1] ?? '';
|
|
1774
|
+
if (lineComment) {
|
|
1775
|
+
if (char === '\n')
|
|
1776
|
+
lineComment = false;
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
if (blockComment) {
|
|
1780
|
+
if (char === '*' && next === '/') {
|
|
1781
|
+
blockComment = false;
|
|
1782
|
+
index += 1;
|
|
1783
|
+
}
|
|
1784
|
+
continue;
|
|
1785
|
+
}
|
|
1786
|
+
if (quote) {
|
|
1787
|
+
if (escaped) {
|
|
1788
|
+
escaped = false;
|
|
1789
|
+
}
|
|
1790
|
+
else if (char === '\\') {
|
|
1791
|
+
escaped = true;
|
|
1792
|
+
}
|
|
1793
|
+
else if (char === quote) {
|
|
1794
|
+
quote = null;
|
|
1795
|
+
}
|
|
1796
|
+
continue;
|
|
1797
|
+
}
|
|
1798
|
+
if (char === '/' && next === '/') {
|
|
1799
|
+
lineComment = true;
|
|
1800
|
+
index += 1;
|
|
1801
|
+
continue;
|
|
1802
|
+
}
|
|
1803
|
+
if (char === '/' && next === '*') {
|
|
1804
|
+
blockComment = true;
|
|
1805
|
+
index += 1;
|
|
1806
|
+
continue;
|
|
1807
|
+
}
|
|
1808
|
+
if (char === '"' || char === "'") {
|
|
1809
|
+
quote = char;
|
|
1810
|
+
continue;
|
|
1811
|
+
}
|
|
1812
|
+
if (char === '`') {
|
|
1813
|
+
index = skipTemplateLiteral(script, index + 1);
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1816
|
+
if (char === '[') {
|
|
1817
|
+
depth += 1;
|
|
1818
|
+
continue;
|
|
1819
|
+
}
|
|
1820
|
+
if (char === ']') {
|
|
1821
|
+
depth -= 1;
|
|
1822
|
+
if (depth === 0)
|
|
1823
|
+
return index;
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
return -1;
|
|
1827
|
+
}
|
|
1828
|
+
function readSimpleTemplateLiteral(script, start) {
|
|
1829
|
+
let value = '';
|
|
1830
|
+
let escaped = false;
|
|
1831
|
+
for (let index = start + 1; index < script.length; index += 1) {
|
|
1832
|
+
const char = script[index] ?? '';
|
|
1833
|
+
const next = script[index + 1] ?? '';
|
|
1834
|
+
if (escaped) {
|
|
1835
|
+
value += char;
|
|
1836
|
+
escaped = false;
|
|
1837
|
+
}
|
|
1838
|
+
else if (char === '\\') {
|
|
1839
|
+
escaped = true;
|
|
1840
|
+
}
|
|
1841
|
+
else if (char === '$' && next === '{') {
|
|
1842
|
+
return null;
|
|
1843
|
+
}
|
|
1844
|
+
else if (char === '`') {
|
|
1845
|
+
return { value, end: index };
|
|
1846
|
+
}
|
|
1847
|
+
else {
|
|
1848
|
+
value += char;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
return null;
|
|
1852
|
+
}
|
|
1853
|
+
function readTemplateLiteralReview(script, start) {
|
|
1854
|
+
const modes = new Set();
|
|
1855
|
+
let dynamic = false;
|
|
1856
|
+
let escaped = false;
|
|
1857
|
+
for (let index = start + 1; index < script.length; index += 1) {
|
|
1858
|
+
const char = script[index] ?? '';
|
|
1859
|
+
const next = script[index + 1] ?? '';
|
|
1860
|
+
if (escaped) {
|
|
1861
|
+
escaped = false;
|
|
1862
|
+
continue;
|
|
1863
|
+
}
|
|
1864
|
+
if (char === '\\') {
|
|
1865
|
+
escaped = true;
|
|
1866
|
+
continue;
|
|
1867
|
+
}
|
|
1868
|
+
if (char === '`') {
|
|
1869
|
+
return {
|
|
1870
|
+
end: index,
|
|
1871
|
+
dynamic,
|
|
1872
|
+
modes: [...modes].sort((left, right) => left.localeCompare(right)),
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
if (char === '$' && next === '{') {
|
|
1876
|
+
const expressionStart = index + 2;
|
|
1877
|
+
const expressionEnd = findMatchingExpressionBrace(script, expressionStart);
|
|
1878
|
+
if (expressionEnd === -1)
|
|
1879
|
+
return null;
|
|
1880
|
+
const nested = workflowRequestedIsolationModes(script.slice(expressionStart, expressionEnd));
|
|
1881
|
+
dynamic = dynamic || nested.dynamic;
|
|
1882
|
+
for (const mode of nested.modes)
|
|
1883
|
+
modes.add(mode);
|
|
1884
|
+
index = expressionEnd;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
return null;
|
|
1888
|
+
}
|
|
1889
|
+
function findMatchingExpressionBrace(script, start) {
|
|
1890
|
+
let depth = 1;
|
|
1891
|
+
let quote = null;
|
|
1892
|
+
let escaped = false;
|
|
1893
|
+
let lineComment = false;
|
|
1894
|
+
let blockComment = false;
|
|
1895
|
+
for (let index = start; index < script.length; index += 1) {
|
|
1896
|
+
const char = script[index] ?? '';
|
|
1897
|
+
const next = script[index + 1] ?? '';
|
|
1898
|
+
if (lineComment) {
|
|
1899
|
+
if (char === '\n')
|
|
1900
|
+
lineComment = false;
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1903
|
+
if (blockComment) {
|
|
1904
|
+
if (char === '*' && next === '/') {
|
|
1905
|
+
blockComment = false;
|
|
1906
|
+
index += 1;
|
|
1907
|
+
}
|
|
1908
|
+
continue;
|
|
1909
|
+
}
|
|
1910
|
+
if (quote) {
|
|
1911
|
+
if (escaped) {
|
|
1912
|
+
escaped = false;
|
|
1913
|
+
}
|
|
1914
|
+
else if (char === '\\') {
|
|
1915
|
+
escaped = true;
|
|
1916
|
+
}
|
|
1917
|
+
else if (char === quote) {
|
|
1918
|
+
quote = null;
|
|
1919
|
+
}
|
|
1920
|
+
continue;
|
|
1921
|
+
}
|
|
1922
|
+
if (char === '/' && next === '/') {
|
|
1923
|
+
lineComment = true;
|
|
1924
|
+
index += 1;
|
|
1925
|
+
continue;
|
|
1926
|
+
}
|
|
1927
|
+
if (char === '/' && next === '*') {
|
|
1928
|
+
blockComment = true;
|
|
1929
|
+
index += 1;
|
|
1930
|
+
continue;
|
|
1931
|
+
}
|
|
1932
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
1933
|
+
quote = char;
|
|
1934
|
+
continue;
|
|
1935
|
+
}
|
|
1936
|
+
if (char === '{')
|
|
1937
|
+
depth += 1;
|
|
1938
|
+
if (char === '}') {
|
|
1939
|
+
depth -= 1;
|
|
1940
|
+
if (depth === 0)
|
|
1941
|
+
return index;
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
return -1;
|
|
1945
|
+
}
|
|
1946
|
+
function splitTopLevelCallArguments(script, openParen) {
|
|
1947
|
+
let depth = 1;
|
|
1948
|
+
let nestedParen = 0;
|
|
1949
|
+
let bracketDepth = 0;
|
|
1950
|
+
let braceDepth = 0;
|
|
1951
|
+
let argStart = openParen + 1;
|
|
1952
|
+
let quote = null;
|
|
1953
|
+
let escaped = false;
|
|
1954
|
+
let lineComment = false;
|
|
1955
|
+
let blockComment = false;
|
|
1956
|
+
const args = [];
|
|
1957
|
+
for (let index = openParen + 1; index < script.length; index += 1) {
|
|
1958
|
+
const char = script[index] ?? '';
|
|
1959
|
+
const next = script[index + 1] ?? '';
|
|
1960
|
+
if (lineComment) {
|
|
1961
|
+
if (char === '\n')
|
|
1962
|
+
lineComment = false;
|
|
1963
|
+
continue;
|
|
1964
|
+
}
|
|
1965
|
+
if (blockComment) {
|
|
1966
|
+
if (char === '*' && next === '/') {
|
|
1967
|
+
blockComment = false;
|
|
1968
|
+
index += 1;
|
|
1969
|
+
}
|
|
1970
|
+
continue;
|
|
1971
|
+
}
|
|
1972
|
+
if (quote) {
|
|
1973
|
+
if (escaped) {
|
|
1974
|
+
escaped = false;
|
|
1975
|
+
}
|
|
1976
|
+
else if (char === '\\') {
|
|
1977
|
+
escaped = true;
|
|
1978
|
+
}
|
|
1979
|
+
else if (char === quote) {
|
|
1980
|
+
quote = null;
|
|
1981
|
+
}
|
|
1982
|
+
continue;
|
|
1983
|
+
}
|
|
1984
|
+
if (char === '/' && next === '/') {
|
|
1985
|
+
lineComment = true;
|
|
1986
|
+
index += 1;
|
|
1987
|
+
continue;
|
|
1988
|
+
}
|
|
1989
|
+
if (char === '/' && next === '*') {
|
|
1990
|
+
blockComment = true;
|
|
1991
|
+
index += 1;
|
|
1992
|
+
continue;
|
|
1993
|
+
}
|
|
1994
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
1995
|
+
quote = char;
|
|
1996
|
+
continue;
|
|
1997
|
+
}
|
|
1998
|
+
if (char === '(') {
|
|
1999
|
+
nestedParen += 1;
|
|
2000
|
+
continue;
|
|
2001
|
+
}
|
|
2002
|
+
if (char === '[') {
|
|
2003
|
+
bracketDepth += 1;
|
|
2004
|
+
continue;
|
|
2005
|
+
}
|
|
2006
|
+
if (char === ']') {
|
|
2007
|
+
bracketDepth = Math.max(0, bracketDepth - 1);
|
|
2008
|
+
continue;
|
|
2009
|
+
}
|
|
2010
|
+
if (char === '{') {
|
|
2011
|
+
braceDepth += 1;
|
|
2012
|
+
continue;
|
|
2013
|
+
}
|
|
2014
|
+
if (char === '}') {
|
|
2015
|
+
braceDepth = Math.max(0, braceDepth - 1);
|
|
2016
|
+
continue;
|
|
2017
|
+
}
|
|
2018
|
+
if (char === ')' && nestedParen === 0 && bracketDepth === 0 && braceDepth === 0) {
|
|
2019
|
+
depth -= 1;
|
|
2020
|
+
if (depth === 0) {
|
|
2021
|
+
const lastArg = script.slice(argStart, index).trim();
|
|
2022
|
+
if (lastArg)
|
|
2023
|
+
args.push(lastArg);
|
|
2024
|
+
return { args, end: index };
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
if (char === ')' && nestedParen > 0) {
|
|
2028
|
+
nestedParen -= 1;
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
2031
|
+
if (char === ',' && nestedParen === 0 && bracketDepth === 0 && braceDepth === 0) {
|
|
2032
|
+
args.push(script.slice(argStart, index));
|
|
2033
|
+
argStart = index + 1;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
return null;
|
|
2037
|
+
}
|
|
2038
|
+
function skipLineComment(script, start) {
|
|
2039
|
+
const lineEnd = script.indexOf('\n', start);
|
|
2040
|
+
return lineEnd === -1 ? script.length : lineEnd;
|
|
2041
|
+
}
|
|
2042
|
+
function skipBlockComment(script, start) {
|
|
2043
|
+
const blockEnd = script.indexOf('*/', start);
|
|
2044
|
+
return blockEnd === -1 ? script.length : blockEnd + 1;
|
|
2045
|
+
}
|
|
2046
|
+
function skipTemplateLiteral(script, start) {
|
|
2047
|
+
let escaped = false;
|
|
2048
|
+
for (let index = start; index < script.length; index += 1) {
|
|
2049
|
+
const char = script[index] ?? '';
|
|
2050
|
+
if (escaped) {
|
|
2051
|
+
escaped = false;
|
|
2052
|
+
}
|
|
2053
|
+
else if (char === '\\') {
|
|
2054
|
+
escaped = true;
|
|
2055
|
+
}
|
|
2056
|
+
else if (char === '`') {
|
|
2057
|
+
return index;
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
return script.length;
|
|
2061
|
+
}
|
|
2062
|
+
function isIdentifierChar(char) {
|
|
2063
|
+
return /[A-Za-z0-9_$]/.test(char);
|
|
2064
|
+
}
|
|
2065
|
+
function workflowScriptMetadataPath(scriptPath) {
|
|
2066
|
+
return `${scriptPath}.meta.json`;
|
|
2067
|
+
}
|
|
2068
|
+
function workflowScriptMetadataFromUnknown(value) {
|
|
2069
|
+
const record = asRecord(value);
|
|
2070
|
+
if (!record || record.version !== 1)
|
|
2071
|
+
return null;
|
|
2072
|
+
if (typeof record.workflowName !== 'string' || !record.workflowName.trim())
|
|
2073
|
+
return null;
|
|
2074
|
+
if (!isWorkflowSource(record.workflowSource))
|
|
2075
|
+
return null;
|
|
2076
|
+
if (typeof record.scriptHash !== 'string' || !record.scriptHash.startsWith('sha256:'))
|
|
2077
|
+
return null;
|
|
2078
|
+
if (record.workflowSourcePath !== undefined && typeof record.workflowSourcePath !== 'string')
|
|
2079
|
+
return null;
|
|
2080
|
+
if (record.permissionKey !== undefined && typeof record.permissionKey !== 'string')
|
|
2081
|
+
return null;
|
|
2082
|
+
return {
|
|
2083
|
+
version: 1,
|
|
2084
|
+
workflowName: record.workflowName,
|
|
2085
|
+
workflowSource: record.workflowSource,
|
|
2086
|
+
...(typeof record.workflowSourcePath === 'string' ? { workflowSourcePath: record.workflowSourcePath } : {}),
|
|
2087
|
+
scriptHash: record.scriptHash,
|
|
2088
|
+
...(typeof record.permissionKey === 'string' ? { permissionKey: record.permissionKey } : {}),
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
function workflowPermissionRecordFromUnknown(value) {
|
|
2092
|
+
const record = asRecord(value);
|
|
2093
|
+
if (!record)
|
|
2094
|
+
return null;
|
|
2095
|
+
if (typeof record.permissionKey !== 'string' || !record.permissionKey)
|
|
2096
|
+
return null;
|
|
2097
|
+
if (record.decision !== 'allow' && record.decision !== 'deny')
|
|
2098
|
+
return null;
|
|
2099
|
+
if (record.reviewVersion !== undefined && (typeof record.reviewVersion !== 'number'
|
|
2100
|
+
|| !Number.isInteger(record.reviewVersion)
|
|
2101
|
+
|| record.reviewVersion < 1))
|
|
2102
|
+
return null;
|
|
2103
|
+
if (typeof record.workflowName !== 'string' || !record.workflowName.trim())
|
|
2104
|
+
return null;
|
|
2105
|
+
if (!isWorkflowSource(record.workflowSource))
|
|
2106
|
+
return null;
|
|
2107
|
+
if (record.workflowSourcePath !== undefined && typeof record.workflowSourcePath !== 'string')
|
|
2108
|
+
return null;
|
|
2109
|
+
if (typeof record.scriptHash !== 'string' || !record.scriptHash.startsWith('sha256:'))
|
|
2110
|
+
return null;
|
|
2111
|
+
if (record.requestedIsolationModes !== undefined && (!Array.isArray(record.requestedIsolationModes)
|
|
2112
|
+
|| record.requestedIsolationModes.some((item) => typeof item !== 'string')))
|
|
2113
|
+
return null;
|
|
2114
|
+
if (record.dynamicIsolation !== undefined && typeof record.dynamicIsolation !== 'boolean')
|
|
2115
|
+
return null;
|
|
2116
|
+
if (record.isolationReviewFingerprint !== undefined && (typeof record.isolationReviewFingerprint !== 'string'
|
|
2117
|
+
|| !record.isolationReviewFingerprint.startsWith('sha256:')))
|
|
2118
|
+
return null;
|
|
2119
|
+
if (typeof record.decidedAt !== 'string' || !record.decidedAt)
|
|
2120
|
+
return null;
|
|
2121
|
+
return {
|
|
2122
|
+
permissionKey: record.permissionKey,
|
|
2123
|
+
decision: record.decision,
|
|
2124
|
+
...(typeof record.reviewVersion === 'number' ? { reviewVersion: record.reviewVersion } : {}),
|
|
2125
|
+
workflowName: record.workflowName,
|
|
2126
|
+
workflowSource: record.workflowSource,
|
|
2127
|
+
...(typeof record.workflowSourcePath === 'string' ? { workflowSourcePath: record.workflowSourcePath } : {}),
|
|
2128
|
+
scriptHash: record.scriptHash,
|
|
2129
|
+
...(Array.isArray(record.requestedIsolationModes) ? { requestedIsolationModes: record.requestedIsolationModes } : {}),
|
|
2130
|
+
...(typeof record.dynamicIsolation === 'boolean' ? { dynamicIsolation: record.dynamicIsolation } : {}),
|
|
2131
|
+
...(typeof record.isolationReviewFingerprint === 'string' ? { isolationReviewFingerprint: record.isolationReviewFingerprint } : {}),
|
|
2132
|
+
decidedAt: record.decidedAt,
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
function workflowPermissionRecordMatchesMetadata(record, metadata, workflowName, scriptHash, isolationReview) {
|
|
2136
|
+
return record?.decision === 'allow'
|
|
2137
|
+
&& record.workflowName === workflowName
|
|
2138
|
+
&& record.workflowSource === metadata.workflowSource
|
|
2139
|
+
&& record.workflowSourcePath === metadata.workflowSourcePath
|
|
2140
|
+
&& record.scriptHash === scriptHash
|
|
2141
|
+
&& workflowPermissionRecordMatchesCurrentReview(record, isolationReview);
|
|
2142
|
+
}
|
|
2143
|
+
function isWorkflowSource(value) {
|
|
2144
|
+
return value === 'inline'
|
|
2145
|
+
|| value === 'script_path'
|
|
2146
|
+
|| value === 'project'
|
|
2147
|
+
|| value === 'user'
|
|
2148
|
+
|| value === 'plugin'
|
|
2149
|
+
|| value === 'built_in';
|
|
2150
|
+
}
|
|
2151
|
+
function workflowPermissionDeniedError(workflowName, workflowSource, scriptHash) {
|
|
2152
|
+
return new UltracodeRequestError(`Workflow permission denied for ${workflowName} (${workflowSource}, ${scriptHash}).`, 403, 'invalid_request_error', WORKFLOW_INPUT_PARAM, 'workflow_permission_denied');
|
|
2153
|
+
}
|
|
2154
|
+
async function workflowScriptFiles(dir) {
|
|
2155
|
+
try {
|
|
2156
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
2157
|
+
return entries
|
|
2158
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.js'))
|
|
2159
|
+
.map((entry) => entry.name)
|
|
2160
|
+
.sort((left, right) => left.localeCompare(right));
|
|
2161
|
+
}
|
|
2162
|
+
catch (err) {
|
|
2163
|
+
const code = err.code;
|
|
2164
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
2165
|
+
return [];
|
|
2166
|
+
throw workflowInputError(`Workflow directory cannot be read: ${dir}`);
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
function workflowFileName(file) {
|
|
2170
|
+
return basename(file, '.js');
|
|
2171
|
+
}
|
|
2172
|
+
function prefixedWorkflowName(name, prefix) {
|
|
2173
|
+
return prefix ? `${prefix}:${name}` : name;
|
|
2174
|
+
}
|
|
2175
|
+
function pluginWorkflowDirs(plugin) {
|
|
2176
|
+
return [
|
|
2177
|
+
...(plugin.workflowsDir ? [plugin.workflowsDir] : []),
|
|
2178
|
+
...(plugin.workflowsDirs ?? []),
|
|
2179
|
+
...(plugin.workflowsPath ? [plugin.workflowsPath] : []),
|
|
2180
|
+
...(plugin.workflowsPaths ?? []),
|
|
2181
|
+
];
|
|
2182
|
+
}
|
|
2183
|
+
function namedWorkflowNotFoundError(name, available) {
|
|
2184
|
+
const names = [...available].sort((left, right) => left.localeCompare(right));
|
|
2185
|
+
const detail = names.length
|
|
2186
|
+
? ` Available workflows: ${names.slice(0, 20).join(', ')}${names.length > 20 ? ', ...' : ''}.`
|
|
2187
|
+
: ' No workflows are registered.';
|
|
2188
|
+
return workflowInputError(`Named workflow not found: ${name}.${detail}`);
|
|
2189
|
+
}
|
|
2190
|
+
function workflowScriptSlug(name) {
|
|
2191
|
+
const slug = name
|
|
2192
|
+
.toLowerCase()
|
|
2193
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
2194
|
+
.replace(/^-+|-+$/g, '');
|
|
2195
|
+
return slug || 'workflow';
|
|
2196
|
+
}
|
|
2197
|
+
async function ensureWorkflowStateDirectory(directoryPath) {
|
|
2198
|
+
await mkdir(directoryPath, { recursive: true, mode: WORKFLOW_STATE_DIR_MODE });
|
|
2199
|
+
await chmod(directoryPath, WORKFLOW_STATE_DIR_MODE).catch(() => undefined);
|
|
2200
|
+
}
|
|
2201
|
+
async function writeWorkflowStateFile(filePath, data, options = {}) {
|
|
2202
|
+
await writeFile(filePath, data, {
|
|
2203
|
+
...options,
|
|
2204
|
+
mode: WORKFLOW_STATE_FILE_MODE,
|
|
2205
|
+
});
|
|
2206
|
+
await chmod(filePath, WORKFLOW_STATE_FILE_MODE).catch(() => undefined);
|
|
2207
|
+
}
|
|
2208
|
+
function pathInsideOrEqual(parent, child) {
|
|
2209
|
+
const candidate = relative(parent, child);
|
|
2210
|
+
return candidate === '' || (!candidate.startsWith('..') && !isAbsolute(candidate));
|
|
2211
|
+
}
|
|
2212
|
+
function normalizeAgentStallRetryLimit(value) {
|
|
2213
|
+
if (value === undefined)
|
|
2214
|
+
return DEFAULT_AGENT_STALL_RETRY_LIMIT;
|
|
2215
|
+
if (!Number.isFinite(value))
|
|
2216
|
+
return DEFAULT_AGENT_STALL_RETRY_LIMIT;
|
|
2217
|
+
return Math.max(0, Math.floor(value));
|
|
2218
|
+
}
|
|
2219
|
+
function normalizeAgentStallTimeoutMs(configured, requestTimeoutMs, retryLimit) {
|
|
2220
|
+
if (configured !== undefined && Number.isFinite(configured) && configured > 0) {
|
|
2221
|
+
return Math.max(1, Math.floor(configured));
|
|
2222
|
+
}
|
|
2223
|
+
const retryAwareTimeout = Math.floor(requestTimeoutMs / Math.max(1, retryLimit + 2));
|
|
2224
|
+
return Math.max(1, Math.min(DEFAULT_AGENT_STALL_TIMEOUT_MS, retryAwareTimeout));
|
|
2225
|
+
}
|
|
2226
|
+
function workflowTaskSnapshot(task) {
|
|
2227
|
+
return {
|
|
2228
|
+
taskId: task.taskId,
|
|
2229
|
+
runId: task.runId,
|
|
2230
|
+
workflowName: task.workflowName,
|
|
2231
|
+
status: task.status,
|
|
2232
|
+
taskType: task.taskType,
|
|
2233
|
+
transcriptDir: task.transcriptDir,
|
|
2234
|
+
scriptPath: task.scriptPath,
|
|
2235
|
+
workflowSource: task.workflowSource,
|
|
2236
|
+
...(task.workflowSourcePath ? { workflowSourcePath: task.workflowSourcePath } : {}),
|
|
2237
|
+
scriptHash: task.scriptHash,
|
|
2238
|
+
...(task.resultPath ? { resultPath: task.resultPath } : {}),
|
|
2239
|
+
...(task.result !== undefined ? { result: task.result } : {}),
|
|
2240
|
+
...(task.error ? { error: task.error } : {}),
|
|
2241
|
+
...(task.failureReason ? { failureReason: task.failureReason } : {}),
|
|
2242
|
+
events: [...task.events],
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
function parseInlineWorkflowScript(script) {
|
|
2246
|
+
if (Buffer.byteLength(script, 'utf8') > MAX_SCRIPT_BYTES) {
|
|
2247
|
+
throw workflowMetaError('Workflow script exceeds the runtime size cap.');
|
|
2248
|
+
}
|
|
2249
|
+
const trimmedStart = script.trimStart();
|
|
2250
|
+
if (!trimmedStart.startsWith('export const meta')) {
|
|
2251
|
+
throw workflowMetaError('Workflow script must start with export const meta = {...};');
|
|
2252
|
+
}
|
|
2253
|
+
rejectForbiddenSyntax(script);
|
|
2254
|
+
const metaStart = script.indexOf('export const meta');
|
|
2255
|
+
const equals = script.indexOf('=', metaStart);
|
|
2256
|
+
if (equals === -1)
|
|
2257
|
+
throw workflowMetaError('Workflow meta declaration must assign a pure object literal.');
|
|
2258
|
+
const objectStart = firstNonWhitespace(script, equals + 1);
|
|
2259
|
+
if (script[objectStart] !== '{') {
|
|
2260
|
+
throw workflowMetaError('Workflow meta must be a pure object literal.');
|
|
2261
|
+
}
|
|
2262
|
+
const objectEnd = findMatchingBrace(script, objectStart);
|
|
2263
|
+
const semicolon = firstNonWhitespace(script, objectEnd + 1);
|
|
2264
|
+
if (script[semicolon] !== ';') {
|
|
2265
|
+
throw workflowMetaError('Workflow meta declaration must end with a semicolon.');
|
|
2266
|
+
}
|
|
2267
|
+
const metaLiteral = script.slice(objectStart, objectEnd + 1);
|
|
2268
|
+
rejectImpureMeta(metaLiteral);
|
|
2269
|
+
const meta = readMetaLiteral(metaLiteral);
|
|
2270
|
+
return {
|
|
2271
|
+
meta,
|
|
2272
|
+
metaLiteral,
|
|
2273
|
+
body: script.slice(semicolon + 1),
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
function rejectForbiddenSyntax(script) {
|
|
2277
|
+
const stripped = stripCommentsAndStrings(script);
|
|
2278
|
+
rejectForbiddenComputedLiteralAccess(script);
|
|
2279
|
+
const rawForbidden = [
|
|
2280
|
+
[/\b(?:constructor|prototype|__proto__)\b/, 'prototype/constructor access is disabled in workflows.'],
|
|
2281
|
+
[/\b(?:process|require|globalThis|global|module|exports)\b/, 'host runtime access is disabled in workflows.'],
|
|
2282
|
+
];
|
|
2283
|
+
for (const [pattern, message] of rawForbidden) {
|
|
2284
|
+
if (pattern.test(stripped))
|
|
2285
|
+
throw workflowScriptError(message);
|
|
2286
|
+
}
|
|
2287
|
+
const checks = [
|
|
2288
|
+
[/\bDate\s*(?:\.|\[|\()/, 'Date is disabled in workflows.'],
|
|
2289
|
+
[/\bnew\s+Date\s*\(\s*\)/, 'argless new Date() is nondeterministic.'],
|
|
2290
|
+
[/\bMath\.random\s*\(/, 'Math.random() is nondeterministic.'],
|
|
2291
|
+
[/\bMath\s*\[/, 'computed Math access is disabled in workflows.'],
|
|
2292
|
+
[/\bimport\s*\(/, 'dynamic import is disabled in workflows.'],
|
|
2293
|
+
[/\beval\s*\(/, 'eval is disabled in workflows.'],
|
|
2294
|
+
[/\bFunction\s*\(/, 'Function constructor is disabled in workflows.'],
|
|
2295
|
+
[/\bWebAssembly\b/, 'WebAssembly code generation is disabled in workflows.'],
|
|
2296
|
+
[/\brequire\s*\(/, 'require is disabled in workflows.'],
|
|
2297
|
+
[/\basync\b/, 'async function definitions are disabled in workflows.'],
|
|
2298
|
+
[/(^|[^?]):\s*(string|number|boolean|unknown|any|Record|Array|Promise)\b/, 'TypeScript syntax is not accepted in workflow scripts.'],
|
|
2299
|
+
];
|
|
2300
|
+
for (const [pattern, message] of checks) {
|
|
2301
|
+
if (pattern.test(stripped))
|
|
2302
|
+
throw workflowScriptError(message);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
function rejectForbiddenComputedLiteralAccess(script) {
|
|
2306
|
+
let quote = null;
|
|
2307
|
+
let escaped = false;
|
|
2308
|
+
let lineComment = false;
|
|
2309
|
+
let blockComment = false;
|
|
2310
|
+
for (let index = 0; index < script.length; index += 1) {
|
|
2311
|
+
const char = script[index] ?? '';
|
|
2312
|
+
const next = script[index + 1] ?? '';
|
|
2313
|
+
if (lineComment) {
|
|
2314
|
+
if (char === '\n')
|
|
2315
|
+
lineComment = false;
|
|
2316
|
+
continue;
|
|
2317
|
+
}
|
|
2318
|
+
if (blockComment) {
|
|
2319
|
+
if (char === '*' && next === '/') {
|
|
2320
|
+
blockComment = false;
|
|
2321
|
+
index += 1;
|
|
2322
|
+
}
|
|
2323
|
+
continue;
|
|
2324
|
+
}
|
|
2325
|
+
if (quote) {
|
|
2326
|
+
if (escaped) {
|
|
2327
|
+
escaped = false;
|
|
2328
|
+
}
|
|
2329
|
+
else if (char === '\\') {
|
|
2330
|
+
escaped = true;
|
|
2331
|
+
}
|
|
2332
|
+
else if (char === quote) {
|
|
2333
|
+
quote = null;
|
|
2334
|
+
}
|
|
2335
|
+
continue;
|
|
2336
|
+
}
|
|
2337
|
+
if (char === '/' && next === '/') {
|
|
2338
|
+
lineComment = true;
|
|
2339
|
+
index += 1;
|
|
2340
|
+
continue;
|
|
2341
|
+
}
|
|
2342
|
+
if (char === '/' && next === '*') {
|
|
2343
|
+
blockComment = true;
|
|
2344
|
+
index += 1;
|
|
2345
|
+
continue;
|
|
2346
|
+
}
|
|
2347
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
2348
|
+
quote = char;
|
|
2349
|
+
continue;
|
|
2350
|
+
}
|
|
2351
|
+
if (char !== '[')
|
|
2352
|
+
continue;
|
|
2353
|
+
const literalStart = firstNonWhitespace(script, index + 1);
|
|
2354
|
+
const literalQuote = script[literalStart];
|
|
2355
|
+
if (literalQuote !== '"' && literalQuote !== "'")
|
|
2356
|
+
continue;
|
|
2357
|
+
const literal = readStringLiteral(script, literalStart, literalQuote);
|
|
2358
|
+
if (!literal)
|
|
2359
|
+
continue;
|
|
2360
|
+
const closeBracket = firstNonWhitespace(script, literal.end + 1);
|
|
2361
|
+
if (script[closeBracket] !== ']')
|
|
2362
|
+
continue;
|
|
2363
|
+
if (FORBIDDEN_HOST_PROPERTY_NAMES.has(literal.value)) {
|
|
2364
|
+
throw workflowScriptError(`computed ${literal.value} access is disabled in workflows.`);
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
function readStringLiteral(text, start, quote) {
|
|
2369
|
+
let value = '';
|
|
2370
|
+
for (let index = start + 1; index < text.length; index += 1) {
|
|
2371
|
+
const char = text[index] ?? '';
|
|
2372
|
+
if (char === quote)
|
|
2373
|
+
return { value, end: index };
|
|
2374
|
+
if (char !== '\\') {
|
|
2375
|
+
value += char;
|
|
2376
|
+
continue;
|
|
2377
|
+
}
|
|
2378
|
+
const escaped = text[index + 1] ?? '';
|
|
2379
|
+
if (escaped === 'u') {
|
|
2380
|
+
const hex = text.slice(index + 2, index + 6);
|
|
2381
|
+
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
|
|
2382
|
+
value += String.fromCharCode(Number.parseInt(hex, 16));
|
|
2383
|
+
index += 5;
|
|
2384
|
+
continue;
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
if (escaped === 'x') {
|
|
2388
|
+
const hex = text.slice(index + 2, index + 4);
|
|
2389
|
+
if (/^[0-9a-fA-F]{2}$/.test(hex)) {
|
|
2390
|
+
value += String.fromCharCode(Number.parseInt(hex, 16));
|
|
2391
|
+
index += 3;
|
|
2392
|
+
continue;
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
value += escaped;
|
|
2396
|
+
index += 1;
|
|
2397
|
+
}
|
|
2398
|
+
return null;
|
|
2399
|
+
}
|
|
2400
|
+
function rejectImpureMeta(metaLiteral) {
|
|
2401
|
+
const stripped = stripCommentsAndStrings(metaLiteral);
|
|
2402
|
+
if (containsTemplateLiteral(metaLiteral)) {
|
|
2403
|
+
throw workflowMetaError('Workflow meta must not use template literals.');
|
|
2404
|
+
}
|
|
2405
|
+
const checks = [
|
|
2406
|
+
[/\.\.\./, 'Workflow meta must not use spread syntax.'],
|
|
2407
|
+
[/\[[^\]]+\]\s*:/, 'Workflow meta must not use computed keys.'],
|
|
2408
|
+
[/\b(get|set)\s+[A-Za-z_$]/, 'Workflow meta must not use accessors.'],
|
|
2409
|
+
[/=>|\bfunction\b/, 'Workflow meta must not use functions.'],
|
|
2410
|
+
[/\bnew\b|\bimport\b|\beval\b/, 'Workflow meta must not call code.'],
|
|
2411
|
+
[/\b(__proto__|constructor|prototype)\b/, 'Workflow meta contains a forbidden key.'],
|
|
2412
|
+
[/[()]/, 'Workflow meta must be a pure literal without calls or grouping.'],
|
|
2413
|
+
];
|
|
2414
|
+
for (const [pattern, message] of checks) {
|
|
2415
|
+
if (pattern.test(stripped))
|
|
2416
|
+
throw workflowMetaError(message);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
function readMetaLiteral(metaLiteral) {
|
|
2420
|
+
let value;
|
|
2421
|
+
try {
|
|
2422
|
+
const context = createWorkflowVmContext();
|
|
2423
|
+
disableDangerousGlobals(context);
|
|
2424
|
+
value = runInContext(`(${metaLiteral})`, context, { timeout: 50 });
|
|
2425
|
+
}
|
|
2426
|
+
catch (err) {
|
|
2427
|
+
throw workflowMetaError(`Workflow meta object is invalid: ${workflowErrorMessage(err)}`);
|
|
2428
|
+
}
|
|
2429
|
+
const meta = asRecord(value);
|
|
2430
|
+
if (!meta || typeof meta.name !== 'string' || meta.name.trim() === '') {
|
|
2431
|
+
throw workflowMetaError('Workflow meta.name must be a non-empty string.');
|
|
2432
|
+
}
|
|
2433
|
+
if (meta.description !== undefined && typeof meta.description !== 'string') {
|
|
2434
|
+
throw workflowMetaError('Workflow meta.description must be a string when present.');
|
|
2435
|
+
}
|
|
2436
|
+
if (meta.phases !== undefined) {
|
|
2437
|
+
if (!Array.isArray(meta.phases))
|
|
2438
|
+
throw workflowMetaError('Workflow meta.phases must be an array.');
|
|
2439
|
+
for (const phase of meta.phases) {
|
|
2440
|
+
const phaseRecord = asRecord(phase);
|
|
2441
|
+
if (!phaseRecord || typeof phaseRecord.title !== 'string' || phaseRecord.title.trim() === '') {
|
|
2442
|
+
throw workflowMetaError('Every workflow phase must have a non-empty title.');
|
|
2443
|
+
}
|
|
2444
|
+
if (phaseRecord.detail !== undefined && typeof phaseRecord.detail !== 'string') {
|
|
2445
|
+
throw workflowMetaError('Workflow phase detail must be a string when present.');
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
return {
|
|
2450
|
+
name: meta.name,
|
|
2451
|
+
...(typeof meta.description === 'string' ? { description: meta.description } : {}),
|
|
2452
|
+
...(Array.isArray(meta.phases) ? { phases: meta.phases } : {}),
|
|
2453
|
+
};
|
|
2454
|
+
}
|
|
2455
|
+
async function executeInlineWorkflow(parsed, globals, signal) {
|
|
2456
|
+
const context = createWorkflowVmContext();
|
|
2457
|
+
installWorkflowVmGlobals(context, globals);
|
|
2458
|
+
const wrapped = [
|
|
2459
|
+
'"use strict";',
|
|
2460
|
+
`const meta = ${parsed.metaLiteral};`,
|
|
2461
|
+
'async function __workflow_main() {',
|
|
2462
|
+
parsed.body,
|
|
2463
|
+
'}',
|
|
2464
|
+
'__workflow_main.call(undefined);',
|
|
2465
|
+
].join('\n');
|
|
2466
|
+
const result = runInContext(wrapped, context, { timeout: 250 });
|
|
2467
|
+
return await abortable(result, signal);
|
|
2468
|
+
}
|
|
2469
|
+
function createWorkflowVmContext() {
|
|
2470
|
+
return createContext({
|
|
2471
|
+
Math: safeMath(),
|
|
2472
|
+
}, {
|
|
2473
|
+
codeGeneration: { strings: false, wasm: false },
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
function installWorkflowVmGlobals(context, globals) {
|
|
2477
|
+
globals.setVmValueProjector(createWorkflowVmValueProjector(context));
|
|
2478
|
+
Object.defineProperty(context, '__workflowHost', {
|
|
2479
|
+
value: globals.host,
|
|
2480
|
+
configurable: true,
|
|
2481
|
+
enumerable: false,
|
|
2482
|
+
writable: false,
|
|
2483
|
+
});
|
|
2484
|
+
runInContext([
|
|
2485
|
+
'"use strict";',
|
|
2486
|
+
'{',
|
|
2487
|
+
' const define = Object.defineProperty;',
|
|
2488
|
+
' const freeze = Object.freeze;',
|
|
2489
|
+
' const __host = globalThis.__workflowHost;',
|
|
2490
|
+
' const NativePromise = Promise;',
|
|
2491
|
+
' delete globalThis.__workflowHost;',
|
|
2492
|
+
' function WorkflowPromise(executor) {',
|
|
2493
|
+
' if (typeof executor !== "function") throw new TypeError("Promise resolver must be a function");',
|
|
2494
|
+
' return __host.trackPromise(new NativePromise(executor));',
|
|
2495
|
+
' }',
|
|
2496
|
+
' define(WorkflowPromise, "resolve", { value: (value) => __host.trackPromise(NativePromise.resolve(value)), writable: false, configurable: false });',
|
|
2497
|
+
' define(WorkflowPromise, "reject", { value: (reason) => __host.trackPromise(NativePromise.reject(reason)), writable: false, configurable: false });',
|
|
2498
|
+
' define(WorkflowPromise, "all", { value: (values) => __host.trackPromise(NativePromise.all(values)), writable: false, configurable: false });',
|
|
2499
|
+
' define(WorkflowPromise, "allSettled", { value: (values) => __host.trackPromise(NativePromise.allSettled(values)), writable: false, configurable: false });',
|
|
2500
|
+
' define(WorkflowPromise, "any", { value: (values) => __host.trackPromise(NativePromise.any(values)), writable: false, configurable: false });',
|
|
2501
|
+
' define(WorkflowPromise, "race", { value: (values) => __host.trackPromise(NativePromise.race(values)), writable: false, configurable: false });',
|
|
2502
|
+
' try { Object.setPrototypeOf(WorkflowPromise, null); } catch {}',
|
|
2503
|
+
' try { Object.setPrototypeOf(WorkflowPromise.prototype, null); } catch {}',
|
|
2504
|
+
` define(globalThis, "args", { value: ${globals.argsLiteral}, writable: false, configurable: false });`,
|
|
2505
|
+
` define(globalThis, "budget", { value: freeze(${globals.budgetLiteral}), writable: false, configurable: false });`,
|
|
2506
|
+
' define(globalThis, "Promise", { value: freeze(WorkflowPromise), writable: false, configurable: false });',
|
|
2507
|
+
' define(globalThis, "agent", { value: (...values) => __host.agent(...values), writable: false, configurable: false });',
|
|
2508
|
+
' define(globalThis, "parallel", { value: (...values) => __host.parallel(...values), writable: false, configurable: false });',
|
|
2509
|
+
' define(globalThis, "pipeline", { value: (...values) => __host.pipeline(...values), writable: false, configurable: false });',
|
|
2510
|
+
' define(globalThis, "phase", { value: (...values) => __host.phase(...values), writable: false, configurable: false });',
|
|
2511
|
+
' define(globalThis, "log", { value: (...values) => __host.log(...values), writable: false, configurable: false });',
|
|
2512
|
+
' define(globalThis, "workflow", { value: (...values) => __host.workflow(...values), writable: false, configurable: false });',
|
|
2513
|
+
' define(globalThis, "setTimeout", { value: (...values) => __host.setTimeout(...values), writable: false, configurable: false });',
|
|
2514
|
+
' define(globalThis, "clearTimeout", { value: (...values) => __host.clearTimeout(...values), writable: false, configurable: false });',
|
|
2515
|
+
' define(globalThis, "console", { value: freeze({',
|
|
2516
|
+
' log: (...values) => __host.consoleLog(...values),',
|
|
2517
|
+
' warn: (...values) => __host.consoleLog(...values),',
|
|
2518
|
+
' error: (...values) => __host.consoleLog(...values),',
|
|
2519
|
+
' }), writable: false, configurable: false });',
|
|
2520
|
+
'}',
|
|
2521
|
+
].join('\n'), context, { timeout: 50 });
|
|
2522
|
+
hardenWorkflowVmIntrinsics(context);
|
|
2523
|
+
disableDangerousGlobals(context);
|
|
2524
|
+
}
|
|
2525
|
+
function createWorkflowVmValueProjector(context) {
|
|
2526
|
+
return (value) => {
|
|
2527
|
+
if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
2528
|
+
return value;
|
|
2529
|
+
}
|
|
2530
|
+
if (value === undefined || typeof value === 'function' || typeof value === 'symbol')
|
|
2531
|
+
return undefined;
|
|
2532
|
+
if (value instanceof Error)
|
|
2533
|
+
return projectWorkflowErrorToVm(context, value);
|
|
2534
|
+
const json = JSON.stringify(workflowVmSerializableValue(value));
|
|
2535
|
+
if (json === undefined)
|
|
2536
|
+
return undefined;
|
|
2537
|
+
Object.defineProperty(context, '__workflowValueJson', {
|
|
2538
|
+
value: json,
|
|
2539
|
+
configurable: true,
|
|
2540
|
+
enumerable: false,
|
|
2541
|
+
writable: true,
|
|
2542
|
+
});
|
|
2543
|
+
try {
|
|
2544
|
+
return runInContext('JSON.parse(__workflowValueJson)', context, { timeout: 50 });
|
|
2545
|
+
}
|
|
2546
|
+
finally {
|
|
2547
|
+
delete context.__workflowValueJson;
|
|
2548
|
+
}
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
2551
|
+
function projectWorkflowErrorToVm(context, err) {
|
|
2552
|
+
const source = err;
|
|
2553
|
+
const payload = {
|
|
2554
|
+
name: err.name,
|
|
2555
|
+
message: err.message,
|
|
2556
|
+
...(typeof source.code === 'string' ? { code: source.code } : {}),
|
|
2557
|
+
};
|
|
2558
|
+
Object.defineProperty(context, '__workflowErrorJson', {
|
|
2559
|
+
value: JSON.stringify(payload),
|
|
2560
|
+
configurable: true,
|
|
2561
|
+
enumerable: false,
|
|
2562
|
+
writable: true,
|
|
2563
|
+
});
|
|
2564
|
+
try {
|
|
2565
|
+
return runInContext([
|
|
2566
|
+
'(() => {',
|
|
2567
|
+
' const payload = JSON.parse(__workflowErrorJson);',
|
|
2568
|
+
' const err = new Error(payload.message);',
|
|
2569
|
+
' try { err.name = payload.name || "Error"; } catch {}',
|
|
2570
|
+
' if (typeof payload.code === "string") {',
|
|
2571
|
+
' try { err.code = payload.code; } catch {}',
|
|
2572
|
+
' }',
|
|
2573
|
+
' return err;',
|
|
2574
|
+
'})()',
|
|
2575
|
+
].join('\n'), context, { timeout: 50 });
|
|
2576
|
+
}
|
|
2577
|
+
finally {
|
|
2578
|
+
delete context.__workflowErrorJson;
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
function workflowVmSerializableValue(value) {
|
|
2582
|
+
return value;
|
|
2583
|
+
}
|
|
2584
|
+
function hardenWorkflowVmIntrinsics(context) {
|
|
2585
|
+
runInContext([
|
|
2586
|
+
'"use strict";',
|
|
2587
|
+
'{',
|
|
2588
|
+
' const define = Object.defineProperty;',
|
|
2589
|
+
' const constructors = [',
|
|
2590
|
+
' Object, Function, Array, String, Number, Boolean, RegExp, Error, TypeError, Promise, Map, Set,',
|
|
2591
|
+
' async function () {}.constructor,',
|
|
2592
|
+
' function* () {}.constructor,',
|
|
2593
|
+
' async function* () {}.constructor,',
|
|
2594
|
+
' ];',
|
|
2595
|
+
' for (const ctor of constructors) {',
|
|
2596
|
+
' if (!ctor || !ctor.prototype) continue;',
|
|
2597
|
+
' try { define(ctor.prototype, "constructor", { value: undefined, writable: false, configurable: false }); } catch {}',
|
|
2598
|
+
' }',
|
|
2599
|
+
' try { define(Object.prototype, "__proto__", { value: undefined, writable: false, configurable: false }); } catch {}',
|
|
2600
|
+
'}',
|
|
2601
|
+
].join('\n'), context, { timeout: 50 });
|
|
2602
|
+
}
|
|
2603
|
+
function disableDangerousGlobals(context) {
|
|
2604
|
+
runInContext([
|
|
2605
|
+
'"use strict";',
|
|
2606
|
+
'{',
|
|
2607
|
+
' const define = Object.defineProperty;',
|
|
2608
|
+
' for (const name of ["Date", "Function", "eval", "WebAssembly", "require", "process", "global", "module", "exports", "Object", "Reflect", "globalThis"]) {',
|
|
2609
|
+
' define(globalThis, name, { value: undefined, writable: false, configurable: false });',
|
|
2610
|
+
' }',
|
|
2611
|
+
'}',
|
|
2612
|
+
].join('\n'), context, { timeout: 50 });
|
|
2613
|
+
}
|
|
2614
|
+
function safeMath() {
|
|
2615
|
+
const math = Object.create(null);
|
|
2616
|
+
for (const key of Object.getOwnPropertyNames(Math)) {
|
|
2617
|
+
if (key === 'random')
|
|
2618
|
+
continue;
|
|
2619
|
+
const descriptor = Object.getOwnPropertyDescriptor(Math, key);
|
|
2620
|
+
if (!descriptor)
|
|
2621
|
+
continue;
|
|
2622
|
+
if ('value' in descriptor && typeof descriptor.value === 'function') {
|
|
2623
|
+
const fn = descriptor.value;
|
|
2624
|
+
Object.defineProperty(math, key, {
|
|
2625
|
+
value: hardenCallable((...args) => fn(...args)),
|
|
2626
|
+
enumerable: descriptor.enumerable,
|
|
2627
|
+
configurable: false,
|
|
2628
|
+
writable: false,
|
|
2629
|
+
});
|
|
2630
|
+
}
|
|
2631
|
+
else {
|
|
2632
|
+
Object.defineProperty(math, key, descriptor);
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
return Object.freeze(math);
|
|
2636
|
+
}
|
|
2637
|
+
function hardenCallable(fn) {
|
|
2638
|
+
Object.setPrototypeOf(fn, null);
|
|
2639
|
+
Object.defineProperty(fn, 'constructor', { value: undefined, configurable: false, writable: false });
|
|
2640
|
+
Object.defineProperty(fn, 'prototype', { value: undefined, configurable: false, writable: false });
|
|
2641
|
+
return Object.freeze(fn);
|
|
2642
|
+
}
|
|
2643
|
+
function handledWorkflowPromise(promise, tracking) {
|
|
2644
|
+
promise.catch(() => undefined);
|
|
2645
|
+
const thenable = Object.create(null);
|
|
2646
|
+
Object.defineProperties(thenable, {
|
|
2647
|
+
then: {
|
|
2648
|
+
value: hardenCallable((onfulfilled, onrejected) => {
|
|
2649
|
+
if (typeof onrejected === 'function' && tracking)
|
|
2650
|
+
tracking.handled = true;
|
|
2651
|
+
const wrappedFulfilled = typeof onfulfilled === 'function'
|
|
2652
|
+
? (value) => onfulfilled((tracking?.projectValue(value) ?? value))
|
|
2653
|
+
: onfulfilled;
|
|
2654
|
+
const wrappedRejected = typeof onrejected === 'function'
|
|
2655
|
+
? (reason) => onrejected(tracking?.projectValue(reason) ?? reason)
|
|
2656
|
+
: onrejected;
|
|
2657
|
+
const nextPromise = promise.then(wrappedFulfilled, wrappedRejected);
|
|
2658
|
+
return tracking ? tracking.trackPromise(nextPromise) : handledWorkflowPromise(nextPromise);
|
|
2659
|
+
}),
|
|
2660
|
+
enumerable: true,
|
|
2661
|
+
configurable: false,
|
|
2662
|
+
writable: false,
|
|
2663
|
+
},
|
|
2664
|
+
catch: {
|
|
2665
|
+
value: hardenCallable((onrejected) => {
|
|
2666
|
+
if (tracking)
|
|
2667
|
+
tracking.handled = true;
|
|
2668
|
+
const wrappedRejected = typeof onrejected === 'function'
|
|
2669
|
+
? (reason) => onrejected(tracking?.projectValue(reason) ?? reason)
|
|
2670
|
+
: onrejected;
|
|
2671
|
+
const nextPromise = promise.catch(wrappedRejected);
|
|
2672
|
+
return tracking ? tracking.trackPromise(nextPromise) : handledWorkflowPromise(nextPromise);
|
|
2673
|
+
}),
|
|
2674
|
+
enumerable: true,
|
|
2675
|
+
configurable: false,
|
|
2676
|
+
writable: false,
|
|
2677
|
+
},
|
|
2678
|
+
finally: {
|
|
2679
|
+
value: hardenCallable((onfinally) => {
|
|
2680
|
+
if (tracking)
|
|
2681
|
+
tracking.handled = true;
|
|
2682
|
+
const nextPromise = promise.finally(onfinally);
|
|
2683
|
+
return tracking ? tracking.trackPromise(nextPromise) : handledWorkflowPromise(nextPromise);
|
|
2684
|
+
}),
|
|
2685
|
+
enumerable: true,
|
|
2686
|
+
configurable: false,
|
|
2687
|
+
writable: false,
|
|
2688
|
+
},
|
|
2689
|
+
});
|
|
2690
|
+
return Object.freeze(thenable);
|
|
2691
|
+
}
|
|
2692
|
+
function vmDataLiteral(value, label) {
|
|
2693
|
+
if (value === undefined)
|
|
2694
|
+
return 'undefined';
|
|
2695
|
+
try {
|
|
2696
|
+
const serialized = JSON.stringify(value);
|
|
2697
|
+
if (serialized === undefined) {
|
|
2698
|
+
throw new Error(`${label} must be JSON-serializable for workflow VM.`);
|
|
2699
|
+
}
|
|
2700
|
+
return serialized;
|
|
2701
|
+
}
|
|
2702
|
+
catch (err) {
|
|
2703
|
+
throw workflowInputError(workflowErrorMessage(err, `${label} must be JSON-serializable for workflow VM.`));
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
async function abortable(value, signal) {
|
|
2707
|
+
if (signal.aborted)
|
|
2708
|
+
throw workflowInputError('Workflow is aborted.');
|
|
2709
|
+
return await Promise.race([
|
|
2710
|
+
Promise.resolve(value),
|
|
2711
|
+
new Promise((_, reject) => {
|
|
2712
|
+
signal.addEventListener('abort', () => reject(workflowInputError('Workflow is aborted.')), { once: true });
|
|
2713
|
+
}),
|
|
2714
|
+
]);
|
|
2715
|
+
}
|
|
2716
|
+
async function sleep(ms) {
|
|
2717
|
+
await new Promise((resolve) => {
|
|
2718
|
+
setTimeout(resolve, ms);
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
async function waitForWorkflowTaskEvent(task, signal) {
|
|
2722
|
+
if (signal?.aborted)
|
|
2723
|
+
return false;
|
|
2724
|
+
return await new Promise((resolve) => {
|
|
2725
|
+
let settled = false;
|
|
2726
|
+
const cleanup = () => {
|
|
2727
|
+
const index = task.waiters.indexOf(waiter);
|
|
2728
|
+
if (index !== -1)
|
|
2729
|
+
task.waiters.splice(index, 1);
|
|
2730
|
+
signal?.removeEventListener('abort', abort);
|
|
2731
|
+
};
|
|
2732
|
+
const finish = (value) => {
|
|
2733
|
+
if (settled)
|
|
2734
|
+
return;
|
|
2735
|
+
settled = true;
|
|
2736
|
+
cleanup();
|
|
2737
|
+
resolve(value);
|
|
2738
|
+
};
|
|
2739
|
+
const waiter = () => finish(true);
|
|
2740
|
+
const abort = () => finish(false);
|
|
2741
|
+
task.waiters.push(waiter);
|
|
2742
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
2743
|
+
if (signal?.aborted)
|
|
2744
|
+
abort();
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
async function mapWithConcurrency(items, concurrency, fn) {
|
|
2748
|
+
const results = new Array(items.length);
|
|
2749
|
+
let nextIndex = 0;
|
|
2750
|
+
const workerCount = Math.min(concurrency, items.length);
|
|
2751
|
+
await Promise.all(Array.from({ length: workerCount }, async () => {
|
|
2752
|
+
while (nextIndex < items.length) {
|
|
2753
|
+
const index = nextIndex;
|
|
2754
|
+
nextIndex += 1;
|
|
2755
|
+
results[index] = await fn(items[index], index);
|
|
2756
|
+
}
|
|
2757
|
+
}));
|
|
2758
|
+
return results;
|
|
2759
|
+
}
|
|
2760
|
+
function isTerminalWorkflowEvent(event) {
|
|
2761
|
+
return event.type === 'workflow.completed' || event.type === 'workflow.failed';
|
|
2762
|
+
}
|
|
2763
|
+
function agentRequest(input) {
|
|
2764
|
+
const tools = input.schema ? [{
|
|
2765
|
+
name: STRUCTURED_OUTPUT_TOOL_NAME,
|
|
2766
|
+
description: 'Submit the canonical structured return value for this workflow agent.',
|
|
2767
|
+
inputSchema: input.schema,
|
|
2768
|
+
}] : [];
|
|
2769
|
+
const prompt = input.worktreePath
|
|
2770
|
+
? [
|
|
2771
|
+
input.prompt,
|
|
2772
|
+
'',
|
|
2773
|
+
'Worktree isolation is enabled. The runtime has set your current working directory to the isolated worktree.',
|
|
2774
|
+
'Make any file changes inside the current working directory only.',
|
|
2775
|
+
].join('\n')
|
|
2776
|
+
: input.prompt;
|
|
2777
|
+
return {
|
|
2778
|
+
model: input.model,
|
|
2779
|
+
messages: [{
|
|
2780
|
+
role: 'user',
|
|
2781
|
+
content: prompt,
|
|
2782
|
+
}],
|
|
2783
|
+
reasoningEffort: 'xhigh',
|
|
2784
|
+
tools,
|
|
2785
|
+
toolChoice: input.schema ? { type: 'required' } : { type: 'auto' },
|
|
2786
|
+
...(input.worktreePath ? { worktreePath: input.worktreePath } : {}),
|
|
2787
|
+
raw: {
|
|
2788
|
+
localWorkflowAgent: true,
|
|
2789
|
+
...(input.schema ? { structuredOutput: true } : {}),
|
|
2790
|
+
...(input.worktreePath ? { isolation: 'worktree' } : {}),
|
|
2791
|
+
},
|
|
2792
|
+
};
|
|
2793
|
+
}
|
|
2794
|
+
function agentResultText(result) {
|
|
2795
|
+
if (result.text)
|
|
2796
|
+
return result.text;
|
|
2797
|
+
if (result.toolCalls.length > 0)
|
|
2798
|
+
return JSON.stringify(result.toolCalls);
|
|
2799
|
+
return '';
|
|
2800
|
+
}
|
|
2801
|
+
function structuredAgentResult(result, schema) {
|
|
2802
|
+
const structuredCalls = result.toolCalls.filter((call) => call.name === STRUCTURED_OUTPUT_TOOL_NAME);
|
|
2803
|
+
if (structuredCalls.length !== 1 || result.toolCalls.length !== 1) {
|
|
2804
|
+
throw workflowStructuredOutputError('StructuredOutput tool call is required for schema-based workflow agents.');
|
|
2805
|
+
}
|
|
2806
|
+
let value;
|
|
2807
|
+
try {
|
|
2808
|
+
value = JSON.parse(structuredCalls[0]?.arguments ?? '');
|
|
2809
|
+
}
|
|
2810
|
+
catch (err) {
|
|
2811
|
+
throw workflowStructuredOutputError(`StructuredOutput arguments must be valid JSON: ${workflowErrorMessage(err)}`);
|
|
2812
|
+
}
|
|
2813
|
+
const error = validateJsonSchemaValue(value, schema);
|
|
2814
|
+
if (error)
|
|
2815
|
+
throw workflowStructuredOutputError(error);
|
|
2816
|
+
return value;
|
|
2817
|
+
}
|
|
2818
|
+
function normalizeStructuredOutputSchema(value) {
|
|
2819
|
+
if (value === undefined)
|
|
2820
|
+
return undefined;
|
|
2821
|
+
let parsed;
|
|
2822
|
+
try {
|
|
2823
|
+
parsed = JSON.parse(vmDataLiteral(value, 'agent schema'));
|
|
2824
|
+
}
|
|
2825
|
+
catch (err) {
|
|
2826
|
+
if (err instanceof UltracodeRequestError)
|
|
2827
|
+
throw err;
|
|
2828
|
+
throw workflowInputError(workflowErrorMessage(err, 'agent schema must be JSON-serializable.'));
|
|
2829
|
+
}
|
|
2830
|
+
const schema = asRecord(parsed);
|
|
2831
|
+
if (!schema)
|
|
2832
|
+
throw workflowInputError('agent schema must be a JSON Schema object.');
|
|
2833
|
+
assertSupportedJsonSchema(schema, 'agent schema');
|
|
2834
|
+
return schema;
|
|
2835
|
+
}
|
|
2836
|
+
function assertSupportedJsonSchema(schema, path) {
|
|
2837
|
+
if (typeof schema === 'boolean')
|
|
2838
|
+
return;
|
|
2839
|
+
const record = asRecord(schema);
|
|
2840
|
+
if (!record)
|
|
2841
|
+
throw workflowInputError(`${path} must be a JSON Schema object.`);
|
|
2842
|
+
for (const key of Object.keys(record)) {
|
|
2843
|
+
if (!JSON_SCHEMA_KEYS.has(key)) {
|
|
2844
|
+
throw workflowInputError(`${path}.${key} is not supported by the workflow schema validator.`);
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
const type = record.type;
|
|
2848
|
+
if (type !== undefined) {
|
|
2849
|
+
const types = Array.isArray(type) ? type : [type];
|
|
2850
|
+
if (types.length === 0
|
|
2851
|
+
|| !types.every((item) => typeof item === 'string' && JSON_SCHEMA_TYPES.has(item))) {
|
|
2852
|
+
throw workflowInputError(`${path}.type must be a JSON Schema primitive type or type array.`);
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
const properties = record.properties;
|
|
2856
|
+
if (properties !== undefined) {
|
|
2857
|
+
const propertySchemas = asRecord(properties);
|
|
2858
|
+
if (!propertySchemas)
|
|
2859
|
+
throw workflowInputError(`${path}.properties must be an object.`);
|
|
2860
|
+
for (const [key, child] of Object.entries(propertySchemas)) {
|
|
2861
|
+
assertSupportedJsonSchema(child, `${path}.properties.${key}`);
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
const required = record.required;
|
|
2865
|
+
if (required !== undefined && (!Array.isArray(required) || !required.every((item) => typeof item === 'string'))) {
|
|
2866
|
+
throw workflowInputError(`${path}.required must be an array of strings.`);
|
|
2867
|
+
}
|
|
2868
|
+
const additionalProperties = record.additionalProperties;
|
|
2869
|
+
if (additionalProperties !== undefined
|
|
2870
|
+
&& typeof additionalProperties !== 'boolean') {
|
|
2871
|
+
assertSupportedJsonSchema(additionalProperties, `${path}.additionalProperties`);
|
|
2872
|
+
}
|
|
2873
|
+
if (record.items !== undefined) {
|
|
2874
|
+
assertSupportedJsonSchema(record.items, `${path}.items`);
|
|
2875
|
+
}
|
|
2876
|
+
if (record.enum !== undefined && !Array.isArray(record.enum)) {
|
|
2877
|
+
throw workflowInputError(`${path}.enum must be an array.`);
|
|
2878
|
+
}
|
|
2879
|
+
for (const key of ['minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems']) {
|
|
2880
|
+
const constraint = record[key];
|
|
2881
|
+
if (constraint !== undefined && (typeof constraint !== 'number' || !Number.isFinite(constraint))) {
|
|
2882
|
+
throw workflowInputError(`${path}.${key} must be a finite number.`);
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
function validateJsonSchemaValue(value, schema, path = '$') {
|
|
2887
|
+
if (schema === true)
|
|
2888
|
+
return null;
|
|
2889
|
+
if (schema === false)
|
|
2890
|
+
return `${path} is rejected by schema.`;
|
|
2891
|
+
const record = asRecord(schema);
|
|
2892
|
+
if (!record)
|
|
2893
|
+
return `${path} schema is invalid.`;
|
|
2894
|
+
if (Array.isArray(record.enum) && !record.enum.some((item) => jsonDeepEqual(item, value))) {
|
|
2895
|
+
return `${path} must equal one of the schema enum values.`;
|
|
2896
|
+
}
|
|
2897
|
+
const typeError = validateJsonSchemaType(value, record.type, path);
|
|
2898
|
+
if (typeError)
|
|
2899
|
+
return typeError;
|
|
2900
|
+
if (isJsonObject(value)) {
|
|
2901
|
+
const required = Array.isArray(record.required) ? record.required : [];
|
|
2902
|
+
for (const key of required) {
|
|
2903
|
+
if (!Object.prototype.hasOwnProperty.call(value, key))
|
|
2904
|
+
return `${path}.${key} is required by schema.`;
|
|
2905
|
+
}
|
|
2906
|
+
const properties = asRecord(record.properties) ?? {};
|
|
2907
|
+
for (const [key, childSchema] of Object.entries(properties)) {
|
|
2908
|
+
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
2909
|
+
const childError = validateJsonSchemaValue(value[key], childSchema, `${path}.${key}`);
|
|
2910
|
+
if (childError)
|
|
2911
|
+
return childError;
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
const additionalProperties = record.additionalProperties;
|
|
2915
|
+
if (additionalProperties !== undefined) {
|
|
2916
|
+
for (const key of Object.keys(value)) {
|
|
2917
|
+
if (Object.prototype.hasOwnProperty.call(properties, key))
|
|
2918
|
+
continue;
|
|
2919
|
+
if (additionalProperties === false)
|
|
2920
|
+
return `${path}.${key} is not allowed by schema.`;
|
|
2921
|
+
if (additionalProperties !== true) {
|
|
2922
|
+
const childError = validateJsonSchemaValue(value[key], additionalProperties, `${path}.${key}`);
|
|
2923
|
+
if (childError)
|
|
2924
|
+
return childError;
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
if (Array.isArray(value)) {
|
|
2930
|
+
const minItems = numberConstraint(record.minItems);
|
|
2931
|
+
if (minItems !== undefined && value.length < minItems)
|
|
2932
|
+
return `${path} must contain at least ${minItems} items.`;
|
|
2933
|
+
const maxItems = numberConstraint(record.maxItems);
|
|
2934
|
+
if (maxItems !== undefined && value.length > maxItems)
|
|
2935
|
+
return `${path} must contain at most ${maxItems} items.`;
|
|
2936
|
+
if (record.items !== undefined) {
|
|
2937
|
+
for (const [index, item] of value.entries()) {
|
|
2938
|
+
const itemError = validateJsonSchemaValue(item, record.items, `${path}[${index}]`);
|
|
2939
|
+
if (itemError)
|
|
2940
|
+
return itemError;
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
if (typeof value === 'string') {
|
|
2945
|
+
const minLength = numberConstraint(record.minLength);
|
|
2946
|
+
if (minLength !== undefined && value.length < minLength)
|
|
2947
|
+
return `${path} must contain at least ${minLength} characters.`;
|
|
2948
|
+
const maxLength = numberConstraint(record.maxLength);
|
|
2949
|
+
if (maxLength !== undefined && value.length > maxLength)
|
|
2950
|
+
return `${path} must contain at most ${maxLength} characters.`;
|
|
2951
|
+
}
|
|
2952
|
+
if (typeof value === 'number') {
|
|
2953
|
+
const minimum = numberConstraint(record.minimum);
|
|
2954
|
+
if (minimum !== undefined && value < minimum)
|
|
2955
|
+
return `${path} must be at least ${minimum}.`;
|
|
2956
|
+
const maximum = numberConstraint(record.maximum);
|
|
2957
|
+
if (maximum !== undefined && value > maximum)
|
|
2958
|
+
return `${path} must be at most ${maximum}.`;
|
|
2959
|
+
}
|
|
2960
|
+
return null;
|
|
2961
|
+
}
|
|
2962
|
+
function validateJsonSchemaType(value, type, path) {
|
|
2963
|
+
if (type === undefined)
|
|
2964
|
+
return null;
|
|
2965
|
+
const types = Array.isArray(type) ? type : [type];
|
|
2966
|
+
if (types.some((item) => typeof item === 'string' && jsonTypeMatches(value, item)))
|
|
2967
|
+
return null;
|
|
2968
|
+
return `${path} must be ${types.join(' or ')}.`;
|
|
2969
|
+
}
|
|
2970
|
+
function jsonTypeMatches(value, type) {
|
|
2971
|
+
if (type === 'null')
|
|
2972
|
+
return value === null;
|
|
2973
|
+
if (type === 'array')
|
|
2974
|
+
return Array.isArray(value);
|
|
2975
|
+
if (type === 'object')
|
|
2976
|
+
return isJsonObject(value);
|
|
2977
|
+
if (type === 'integer')
|
|
2978
|
+
return typeof value === 'number' && Number.isInteger(value);
|
|
2979
|
+
return typeof value === type;
|
|
2980
|
+
}
|
|
2981
|
+
function isJsonObject(value) {
|
|
2982
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
2983
|
+
}
|
|
2984
|
+
function numberConstraint(value) {
|
|
2985
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
2986
|
+
}
|
|
2987
|
+
function jsonDeepEqual(left, right) {
|
|
2988
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
2989
|
+
}
|
|
2990
|
+
function findMatchingBrace(text, start) {
|
|
2991
|
+
let depth = 0;
|
|
2992
|
+
let quote = null;
|
|
2993
|
+
let escaped = false;
|
|
2994
|
+
let lineComment = false;
|
|
2995
|
+
let blockComment = false;
|
|
2996
|
+
for (let index = start; index < text.length; index += 1) {
|
|
2997
|
+
const char = text[index] ?? '';
|
|
2998
|
+
const next = text[index + 1] ?? '';
|
|
2999
|
+
if (lineComment) {
|
|
3000
|
+
if (char === '\n')
|
|
3001
|
+
lineComment = false;
|
|
3002
|
+
continue;
|
|
3003
|
+
}
|
|
3004
|
+
if (blockComment) {
|
|
3005
|
+
if (char === '*' && next === '/') {
|
|
3006
|
+
blockComment = false;
|
|
3007
|
+
index += 1;
|
|
3008
|
+
}
|
|
3009
|
+
continue;
|
|
3010
|
+
}
|
|
3011
|
+
if (quote) {
|
|
3012
|
+
if (escaped) {
|
|
3013
|
+
escaped = false;
|
|
3014
|
+
}
|
|
3015
|
+
else if (char === '\\') {
|
|
3016
|
+
escaped = true;
|
|
3017
|
+
}
|
|
3018
|
+
else if (char === quote) {
|
|
3019
|
+
quote = null;
|
|
3020
|
+
}
|
|
3021
|
+
continue;
|
|
3022
|
+
}
|
|
3023
|
+
if (char === '/' && next === '/') {
|
|
3024
|
+
lineComment = true;
|
|
3025
|
+
index += 1;
|
|
3026
|
+
continue;
|
|
3027
|
+
}
|
|
3028
|
+
if (char === '/' && next === '*') {
|
|
3029
|
+
blockComment = true;
|
|
3030
|
+
index += 1;
|
|
3031
|
+
continue;
|
|
3032
|
+
}
|
|
3033
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
3034
|
+
quote = char;
|
|
3035
|
+
continue;
|
|
3036
|
+
}
|
|
3037
|
+
if (char === '{')
|
|
3038
|
+
depth += 1;
|
|
3039
|
+
if (char === '}') {
|
|
3040
|
+
depth -= 1;
|
|
3041
|
+
if (depth === 0)
|
|
3042
|
+
return index;
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
throw workflowMetaError('Workflow meta object is not closed.');
|
|
3046
|
+
}
|
|
3047
|
+
function firstNonWhitespace(text, start) {
|
|
3048
|
+
for (let index = start; index < text.length; index += 1) {
|
|
3049
|
+
if (!/\s/.test(text[index] ?? ''))
|
|
3050
|
+
return index;
|
|
3051
|
+
}
|
|
3052
|
+
return text.length;
|
|
3053
|
+
}
|
|
3054
|
+
function firstNonCodeWhitespace(text, start) {
|
|
3055
|
+
for (let index = start; index < text.length; index += 1) {
|
|
3056
|
+
const char = text[index] ?? '';
|
|
3057
|
+
const next = text[index + 1] ?? '';
|
|
3058
|
+
if (/\s/.test(char))
|
|
3059
|
+
continue;
|
|
3060
|
+
if (char === '/' && next === '/') {
|
|
3061
|
+
index = skipLineComment(text, index + 2);
|
|
3062
|
+
continue;
|
|
3063
|
+
}
|
|
3064
|
+
if (char === '/' && next === '*') {
|
|
3065
|
+
index = skipBlockComment(text, index + 2);
|
|
3066
|
+
continue;
|
|
3067
|
+
}
|
|
3068
|
+
return index;
|
|
3069
|
+
}
|
|
3070
|
+
return text.length;
|
|
3071
|
+
}
|
|
3072
|
+
function stripCommentsAndStrings(text) {
|
|
3073
|
+
let out = '';
|
|
3074
|
+
let quote = null;
|
|
3075
|
+
let escaped = false;
|
|
3076
|
+
let lineComment = false;
|
|
3077
|
+
let blockComment = false;
|
|
3078
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
3079
|
+
const char = text[index] ?? '';
|
|
3080
|
+
const next = text[index + 1] ?? '';
|
|
3081
|
+
if (lineComment) {
|
|
3082
|
+
out += char === '\n' ? '\n' : ' ';
|
|
3083
|
+
if (char === '\n')
|
|
3084
|
+
lineComment = false;
|
|
3085
|
+
continue;
|
|
3086
|
+
}
|
|
3087
|
+
if (blockComment) {
|
|
3088
|
+
if (char === '*' && next === '/') {
|
|
3089
|
+
out += ' ';
|
|
3090
|
+
blockComment = false;
|
|
3091
|
+
index += 1;
|
|
3092
|
+
}
|
|
3093
|
+
else {
|
|
3094
|
+
out += char === '\n' ? '\n' : ' ';
|
|
3095
|
+
}
|
|
3096
|
+
continue;
|
|
3097
|
+
}
|
|
3098
|
+
if (quote) {
|
|
3099
|
+
out += char === '\n' ? '\n' : ' ';
|
|
3100
|
+
if (escaped) {
|
|
3101
|
+
escaped = false;
|
|
3102
|
+
}
|
|
3103
|
+
else if (char === '\\') {
|
|
3104
|
+
escaped = true;
|
|
3105
|
+
}
|
|
3106
|
+
else if (char === quote) {
|
|
3107
|
+
quote = null;
|
|
3108
|
+
}
|
|
3109
|
+
continue;
|
|
3110
|
+
}
|
|
3111
|
+
if (char === '/' && next === '/') {
|
|
3112
|
+
out += ' ';
|
|
3113
|
+
lineComment = true;
|
|
3114
|
+
index += 1;
|
|
3115
|
+
continue;
|
|
3116
|
+
}
|
|
3117
|
+
if (char === '/' && next === '*') {
|
|
3118
|
+
out += ' ';
|
|
3119
|
+
blockComment = true;
|
|
3120
|
+
index += 1;
|
|
3121
|
+
continue;
|
|
3122
|
+
}
|
|
3123
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
3124
|
+
out += ' ';
|
|
3125
|
+
quote = char;
|
|
3126
|
+
continue;
|
|
3127
|
+
}
|
|
3128
|
+
out += char;
|
|
3129
|
+
}
|
|
3130
|
+
return out;
|
|
3131
|
+
}
|
|
3132
|
+
function containsTemplateLiteral(text) {
|
|
3133
|
+
let quote = null;
|
|
3134
|
+
let escaped = false;
|
|
3135
|
+
let lineComment = false;
|
|
3136
|
+
let blockComment = false;
|
|
3137
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
3138
|
+
const char = text[index] ?? '';
|
|
3139
|
+
const next = text[index + 1] ?? '';
|
|
3140
|
+
if (lineComment) {
|
|
3141
|
+
if (char === '\n')
|
|
3142
|
+
lineComment = false;
|
|
3143
|
+
continue;
|
|
3144
|
+
}
|
|
3145
|
+
if (blockComment) {
|
|
3146
|
+
if (char === '*' && next === '/') {
|
|
3147
|
+
blockComment = false;
|
|
3148
|
+
index += 1;
|
|
3149
|
+
}
|
|
3150
|
+
continue;
|
|
3151
|
+
}
|
|
3152
|
+
if (quote) {
|
|
3153
|
+
if (escaped) {
|
|
3154
|
+
escaped = false;
|
|
3155
|
+
}
|
|
3156
|
+
else if (char === '\\') {
|
|
3157
|
+
escaped = true;
|
|
3158
|
+
}
|
|
3159
|
+
else if (char === quote) {
|
|
3160
|
+
quote = null;
|
|
3161
|
+
}
|
|
3162
|
+
continue;
|
|
3163
|
+
}
|
|
3164
|
+
if (char === '/' && next === '/') {
|
|
3165
|
+
lineComment = true;
|
|
3166
|
+
index += 1;
|
|
3167
|
+
continue;
|
|
3168
|
+
}
|
|
3169
|
+
if (char === '/' && next === '*') {
|
|
3170
|
+
blockComment = true;
|
|
3171
|
+
index += 1;
|
|
3172
|
+
continue;
|
|
3173
|
+
}
|
|
3174
|
+
if (char === '"' || char === "'") {
|
|
3175
|
+
quote = char;
|
|
3176
|
+
continue;
|
|
3177
|
+
}
|
|
3178
|
+
if (char === '`')
|
|
3179
|
+
return true;
|
|
3180
|
+
}
|
|
3181
|
+
return false;
|
|
3182
|
+
}
|
|
3183
|
+
function preview(text, limit) {
|
|
3184
|
+
const normalized = text.replace(/\s+/g, ' ').trim();
|
|
3185
|
+
return normalized.length <= limit ? normalized : `${normalized.slice(0, limit - 1)}...`;
|
|
3186
|
+
}
|
|
3187
|
+
function previewValue(value, limit) {
|
|
3188
|
+
const text = typeof value === 'string'
|
|
3189
|
+
? value
|
|
3190
|
+
: JSON.stringify(value) ?? String(value);
|
|
3191
|
+
return preview(text, limit);
|
|
3192
|
+
}
|
|
3193
|
+
function asRecord(value) {
|
|
3194
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
3195
|
+
return null;
|
|
3196
|
+
return value;
|
|
3197
|
+
}
|
|
3198
|
+
function workflowErrorMessage(err, fallback = 'Workflow failed.') {
|
|
3199
|
+
if (err instanceof Error)
|
|
3200
|
+
return err.message;
|
|
3201
|
+
const record = asRecord(err);
|
|
3202
|
+
if (typeof record?.message === 'string')
|
|
3203
|
+
return record.message;
|
|
3204
|
+
const text = String(err);
|
|
3205
|
+
return text || fallback;
|
|
3206
|
+
}
|
|
3207
|
+
function workflowAgentSemanticOpts(input) {
|
|
3208
|
+
return {
|
|
3209
|
+
...(input.schema ? { schema: journalJsonValueOrInputError(input.schema, 'agent schema') } : {}),
|
|
3210
|
+
model: input.model,
|
|
3211
|
+
effort: input.effort,
|
|
3212
|
+
...(input.isolation ? { isolation: input.isolation } : {}),
|
|
3213
|
+
};
|
|
3214
|
+
}
|
|
3215
|
+
function workflowUsage(usage) {
|
|
3216
|
+
const inputTokens = finiteTokenCount(usage.inputTokens);
|
|
3217
|
+
const outputTokens = finiteTokenCount(usage.outputTokens);
|
|
3218
|
+
return {
|
|
3219
|
+
inputTokens,
|
|
3220
|
+
outputTokens,
|
|
3221
|
+
totalTokens: finiteTokenCount(usage.totalTokens ?? inputTokens + outputTokens),
|
|
3222
|
+
};
|
|
3223
|
+
}
|
|
3224
|
+
function finiteTokenCount(value) {
|
|
3225
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
3226
|
+
}
|
|
3227
|
+
function journalJsonValueOrInputError(value, label) {
|
|
3228
|
+
try {
|
|
3229
|
+
return normalizeJournalJsonValue(value, label);
|
|
3230
|
+
}
|
|
3231
|
+
catch (err) {
|
|
3232
|
+
throw workflowInputError(workflowErrorMessage(err, `${label} must be JSON-serializable.`));
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
function workflowJournalRuntimeError(err) {
|
|
3236
|
+
if (isWorkflowJournalError(err))
|
|
3237
|
+
return err;
|
|
3238
|
+
return new WorkflowJournalError('Workflow journal write failed.', err);
|
|
3239
|
+
}
|
|
3240
|
+
function workflowJournalRequestError(_err) {
|
|
3241
|
+
return new UltracodeRequestError(WORKFLOW_JOURNAL_PUBLIC_FAILURE_MESSAGE, 500, 'server_error', WORKFLOW_INPUT_PARAM, WORKFLOW_JOURNAL_WRITE_FAILED_REASON);
|
|
3242
|
+
}
|
|
3243
|
+
function workflowResumeRunningError(runId) {
|
|
3244
|
+
return new UltracodeRequestError(`Workflow run is still running and cannot be resumed: ${runId}`, 409, 'invalid_request_error', WORKFLOW_INPUT_PARAM, 'workflow_resume_running');
|
|
3245
|
+
}
|
|
3246
|
+
function workflowFailureReason(err) {
|
|
3247
|
+
if (isWorkflowJournalError(err))
|
|
3248
|
+
return WORKFLOW_JOURNAL_WRITE_FAILED_REASON;
|
|
3249
|
+
if (err instanceof UltracodeRequestError && err.code)
|
|
3250
|
+
return err.code;
|
|
3251
|
+
const record = asRecord(err);
|
|
3252
|
+
if (typeof record?.code === 'string' && WORKFLOW_STABLE_FAILURE_CODES.has(record.code))
|
|
3253
|
+
return record.code;
|
|
3254
|
+
return 'workflow_failed';
|
|
3255
|
+
}
|
|
3256
|
+
function workflowInputError(message) {
|
|
3257
|
+
return new UltracodeRequestError(message, 400, 'invalid_request_error', WORKFLOW_INPUT_PARAM, 'workflow_input_invalid');
|
|
3258
|
+
}
|
|
3259
|
+
function workflowMetaError(message) {
|
|
3260
|
+
return new UltracodeRequestError(message, 400, 'invalid_request_error', WORKFLOW_INPUT_PARAM, 'workflow_meta_invalid');
|
|
3261
|
+
}
|
|
3262
|
+
function workflowScriptError(message) {
|
|
3263
|
+
return new UltracodeRequestError(message, 400, 'invalid_request_error', WORKFLOW_INPUT_PARAM, 'workflow_script_nondeterministic');
|
|
3264
|
+
}
|
|
3265
|
+
function workflowAgentStalledError(message) {
|
|
3266
|
+
return new UltracodeRequestError(message, 408, 'invalid_request_error', WORKFLOW_INPUT_PARAM, 'workflow_agent_stalled');
|
|
3267
|
+
}
|
|
3268
|
+
function isWorkflowAgentStalledError(err) {
|
|
3269
|
+
return err instanceof UltracodeRequestError && err.code === 'workflow_agent_stalled';
|
|
3270
|
+
}
|
|
3271
|
+
function workflowStructuredOutputError(message) {
|
|
3272
|
+
return new UltracodeRequestError(message, 400, 'invalid_request_error', WORKFLOW_INPUT_PARAM, 'workflow_structured_output_failed');
|
|
3273
|
+
}
|
|
3274
|
+
export function workflowResultUsage(events) {
|
|
3275
|
+
const text = events.map((event) => JSON.stringify(event)).join('\n');
|
|
3276
|
+
return {
|
|
3277
|
+
inputTokens: 1,
|
|
3278
|
+
outputTokens: estimateTokens(text),
|
|
3279
|
+
};
|
|
3280
|
+
}
|