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,681 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants as fsConstants } from 'node:fs';
|
|
3
|
+
import { chmod, lstat, mkdir, open, readFile, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
export class WorkflowJournalError extends Error {
|
|
6
|
+
cause;
|
|
7
|
+
code = 'workflow_journal_write_failed';
|
|
8
|
+
constructor(message, cause) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.cause = cause;
|
|
11
|
+
this.name = 'WorkflowJournalError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class WorkflowJournalValidationError extends Error {
|
|
15
|
+
code = 'workflow_journal_invalid';
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'WorkflowJournalValidationError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class WorkflowJournalWriter {
|
|
22
|
+
options;
|
|
23
|
+
journalPath;
|
|
24
|
+
queue = Promise.resolve();
|
|
25
|
+
poisoned;
|
|
26
|
+
seq = 0;
|
|
27
|
+
previousEntryHash = ZERO_HASH;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.options = options;
|
|
30
|
+
this.journalPath = join(options.transcriptDir, JOURNAL_FILE_NAME);
|
|
31
|
+
}
|
|
32
|
+
static async create(options) {
|
|
33
|
+
await ensureWorkflowTranscriptDir(options.transcriptDir);
|
|
34
|
+
const journalPath = join(options.transcriptDir, JOURNAL_FILE_NAME);
|
|
35
|
+
let handle;
|
|
36
|
+
try {
|
|
37
|
+
handle = await open(journalPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, JOURNAL_FILE_MODE);
|
|
38
|
+
await handle.sync();
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
throw new WorkflowJournalError(`Workflow journal cannot be initialized at ${journalPath}.`, err);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await handle?.close().catch(() => undefined);
|
|
45
|
+
}
|
|
46
|
+
await chmod(journalPath, JOURNAL_FILE_MODE).catch(() => undefined);
|
|
47
|
+
await durableDirectorySync(options.transcriptDir, options.durability);
|
|
48
|
+
await durableDirectorySync(dirname(options.transcriptDir), options.durability);
|
|
49
|
+
return new WorkflowJournalWriter(options);
|
|
50
|
+
}
|
|
51
|
+
async append(payload) {
|
|
52
|
+
const terminalPayload = TERMINAL_KINDS.has(payload.kind);
|
|
53
|
+
if (this.poisoned && !terminalPayload)
|
|
54
|
+
throw this.poisoned;
|
|
55
|
+
let entry;
|
|
56
|
+
const queue = terminalPayload ? this.queue.catch(() => undefined) : this.queue;
|
|
57
|
+
const appendWork = queue.then(async () => {
|
|
58
|
+
if (this.poisoned && !terminalPayload)
|
|
59
|
+
throw this.poisoned;
|
|
60
|
+
entry = this.nextEntry(payload);
|
|
61
|
+
await appendJournalLine(this.journalPath, entry, this.options.durability);
|
|
62
|
+
this.seq = entry.seq;
|
|
63
|
+
this.previousEntryHash = entry.entryHash;
|
|
64
|
+
});
|
|
65
|
+
this.queue = appendWork.catch((err) => {
|
|
66
|
+
const journalError = toWorkflowJournalError(err, `Workflow journal append failed at ${this.journalPath}.`);
|
|
67
|
+
if (!terminalPayload && !this.poisoned)
|
|
68
|
+
this.poisoned = journalError;
|
|
69
|
+
throw journalError;
|
|
70
|
+
});
|
|
71
|
+
this.queue.catch(() => undefined);
|
|
72
|
+
try {
|
|
73
|
+
await appendWork;
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
const journalError = toWorkflowJournalError(err, `Workflow journal append failed at ${this.journalPath}.`);
|
|
77
|
+
if (!terminalPayload && !this.poisoned)
|
|
78
|
+
this.poisoned = journalError;
|
|
79
|
+
throw terminalPayload ? journalError : this.poisoned ?? journalError;
|
|
80
|
+
}
|
|
81
|
+
return entry;
|
|
82
|
+
}
|
|
83
|
+
nextEntry(payload) {
|
|
84
|
+
const base = {
|
|
85
|
+
version: 1,
|
|
86
|
+
seq: this.seq + 1,
|
|
87
|
+
previousEntryHash: this.previousEntryHash,
|
|
88
|
+
recordedAt: new Date().toISOString(),
|
|
89
|
+
taskId: this.options.taskId,
|
|
90
|
+
runId: this.options.runId,
|
|
91
|
+
...payload,
|
|
92
|
+
};
|
|
93
|
+
const entryHash = workflowJournalHash(base);
|
|
94
|
+
const entry = { ...base, entryHash };
|
|
95
|
+
assertValidWorkflowJournalEntry(entry);
|
|
96
|
+
return entry;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const JOURNAL_FILE_NAME = 'journal.jsonl';
|
|
100
|
+
const JOURNAL_FILE_MODE = 0o600;
|
|
101
|
+
const TRANSCRIPT_DIR_MODE = 0o700;
|
|
102
|
+
const ZERO_HASH = '0'.repeat(64);
|
|
103
|
+
const MAX_LINE_BYTES = 1024 * 1024;
|
|
104
|
+
const MAX_STRING_BYTES = 512 * 1024;
|
|
105
|
+
const HASH_RE = /^[0-9a-f]{64}$/;
|
|
106
|
+
const TERMINAL_KINDS = new Set(['workflow.run.completed', 'workflow.run.failed']);
|
|
107
|
+
export const WORKFLOW_JOURNAL_GENESIS_HASH = ZERO_HASH;
|
|
108
|
+
export const WORKFLOW_JOURNAL_GENESIS_AGENT_CALL_KEY = ZERO_HASH;
|
|
109
|
+
export const WORKFLOW_JOURNAL_WRITE_FAILED_REASON = 'workflow_journal_write_failed';
|
|
110
|
+
const ENTRY_KEYS = {
|
|
111
|
+
'workflow.run.started': [
|
|
112
|
+
'version',
|
|
113
|
+
'seq',
|
|
114
|
+
'previousEntryHash',
|
|
115
|
+
'entryHash',
|
|
116
|
+
'recordedAt',
|
|
117
|
+
'taskId',
|
|
118
|
+
'runId',
|
|
119
|
+
'kind',
|
|
120
|
+
'workflowName',
|
|
121
|
+
'workflowSource',
|
|
122
|
+
'workflowSourcePath',
|
|
123
|
+
'scriptPath',
|
|
124
|
+
'scriptHash',
|
|
125
|
+
'args',
|
|
126
|
+
'runtime',
|
|
127
|
+
],
|
|
128
|
+
'workflow.agent.started': [
|
|
129
|
+
'version',
|
|
130
|
+
'seq',
|
|
131
|
+
'previousEntryHash',
|
|
132
|
+
'entryHash',
|
|
133
|
+
'recordedAt',
|
|
134
|
+
'taskId',
|
|
135
|
+
'runId',
|
|
136
|
+
'kind',
|
|
137
|
+
'agentIndex',
|
|
138
|
+
'agentId',
|
|
139
|
+
'agentCallKey',
|
|
140
|
+
'previousAgentCallKey',
|
|
141
|
+
'prompt',
|
|
142
|
+
'semanticOpts',
|
|
143
|
+
],
|
|
144
|
+
'workflow.agent.completed': [
|
|
145
|
+
'version',
|
|
146
|
+
'seq',
|
|
147
|
+
'previousEntryHash',
|
|
148
|
+
'entryHash',
|
|
149
|
+
'recordedAt',
|
|
150
|
+
'taskId',
|
|
151
|
+
'runId',
|
|
152
|
+
'kind',
|
|
153
|
+
'agentIndex',
|
|
154
|
+
'agentId',
|
|
155
|
+
'agentCallKey',
|
|
156
|
+
'result',
|
|
157
|
+
'usage',
|
|
158
|
+
'toolCalls',
|
|
159
|
+
],
|
|
160
|
+
'workflow.agent.failed': [
|
|
161
|
+
'version',
|
|
162
|
+
'seq',
|
|
163
|
+
'previousEntryHash',
|
|
164
|
+
'entryHash',
|
|
165
|
+
'recordedAt',
|
|
166
|
+
'taskId',
|
|
167
|
+
'runId',
|
|
168
|
+
'kind',
|
|
169
|
+
'agentIndex',
|
|
170
|
+
'agentId',
|
|
171
|
+
'agentCallKey',
|
|
172
|
+
'reason',
|
|
173
|
+
'message',
|
|
174
|
+
],
|
|
175
|
+
'workflow.run.completed': [
|
|
176
|
+
'version',
|
|
177
|
+
'seq',
|
|
178
|
+
'previousEntryHash',
|
|
179
|
+
'entryHash',
|
|
180
|
+
'recordedAt',
|
|
181
|
+
'taskId',
|
|
182
|
+
'runId',
|
|
183
|
+
'kind',
|
|
184
|
+
'result',
|
|
185
|
+
'resultPath',
|
|
186
|
+
'agentCount',
|
|
187
|
+
'usage',
|
|
188
|
+
'toolCalls',
|
|
189
|
+
'durationMs',
|
|
190
|
+
],
|
|
191
|
+
'workflow.run.failed': [
|
|
192
|
+
'version',
|
|
193
|
+
'seq',
|
|
194
|
+
'previousEntryHash',
|
|
195
|
+
'entryHash',
|
|
196
|
+
'recordedAt',
|
|
197
|
+
'taskId',
|
|
198
|
+
'runId',
|
|
199
|
+
'kind',
|
|
200
|
+
'reason',
|
|
201
|
+
'message',
|
|
202
|
+
'recovery',
|
|
203
|
+
'durationMs',
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
export function workflowJournalPath(transcriptDir) {
|
|
207
|
+
return join(transcriptDir, JOURNAL_FILE_NAME);
|
|
208
|
+
}
|
|
209
|
+
export function isWorkflowJournalError(err) {
|
|
210
|
+
return err instanceof WorkflowJournalError || (Boolean(err) && err.code === WORKFLOW_JOURNAL_WRITE_FAILED_REASON);
|
|
211
|
+
}
|
|
212
|
+
export function normalizeJournalJsonValue(value, label) {
|
|
213
|
+
return normalizeJsonValue(value, label, new WeakSet());
|
|
214
|
+
}
|
|
215
|
+
export function stableJson(value) {
|
|
216
|
+
return stableSerialize(normalizeJournalJsonValue(value, 'value'));
|
|
217
|
+
}
|
|
218
|
+
export function computeWorkflowAgentCallKey(input) {
|
|
219
|
+
if (!HASH_RE.test(input.previousAgentCallKey)) {
|
|
220
|
+
throw new WorkflowJournalValidationError('previousAgentCallKey must be a 64-character sha256 hex digest.');
|
|
221
|
+
}
|
|
222
|
+
return sha256(`${input.previousAgentCallKey}\0${input.prompt}\0${stableJson(input.semanticOpts)}`);
|
|
223
|
+
}
|
|
224
|
+
export function workflowJournalHash(entryWithoutEntryHash) {
|
|
225
|
+
return sha256(stableJson(entryWithoutEntryHash));
|
|
226
|
+
}
|
|
227
|
+
export async function readWorkflowJournal(journalPath) {
|
|
228
|
+
const raw = await readFile(journalPath, 'utf8');
|
|
229
|
+
const endsWithNewline = raw.endsWith('\n');
|
|
230
|
+
const lines = raw.split('\n');
|
|
231
|
+
if (endsWithNewline)
|
|
232
|
+
lines.pop();
|
|
233
|
+
let truncatedTail = false;
|
|
234
|
+
if (!endsWithNewline && lines.length > 0) {
|
|
235
|
+
const tail = lines[lines.length - 1] ?? '';
|
|
236
|
+
try {
|
|
237
|
+
JSON.parse(tail);
|
|
238
|
+
throw new WorkflowJournalValidationError('workflow journal has a non-newline-terminated JSON entry.');
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
if (err instanceof WorkflowJournalValidationError)
|
|
242
|
+
throw err;
|
|
243
|
+
if (!isTruncationParseError(err))
|
|
244
|
+
throw err;
|
|
245
|
+
lines.pop();
|
|
246
|
+
truncatedTail = true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const entries = lines.map((line, index) => parseJournalLine(line, index + 1));
|
|
250
|
+
validateWorkflowJournal(entries);
|
|
251
|
+
return { entries, truncatedTail };
|
|
252
|
+
}
|
|
253
|
+
export async function cleanupWorkflowJournalTranscriptDir(transcriptDir) {
|
|
254
|
+
await rm(transcriptDir, { recursive: true, force: true });
|
|
255
|
+
}
|
|
256
|
+
function parseJournalLine(line, lineNumber) {
|
|
257
|
+
if (Buffer.byteLength(line, 'utf8') > MAX_LINE_BYTES) {
|
|
258
|
+
throw new WorkflowJournalValidationError(`journal line ${lineNumber} exceeds ${MAX_LINE_BYTES} bytes.`);
|
|
259
|
+
}
|
|
260
|
+
let value;
|
|
261
|
+
try {
|
|
262
|
+
value = JSON.parse(line);
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
throw new WorkflowJournalValidationError(`journal line ${lineNumber} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
266
|
+
}
|
|
267
|
+
const entry = value;
|
|
268
|
+
assertValidWorkflowJournalEntry(entry);
|
|
269
|
+
return entry;
|
|
270
|
+
}
|
|
271
|
+
function validateWorkflowJournal(entries) {
|
|
272
|
+
if (entries.length === 0)
|
|
273
|
+
throw new WorkflowJournalValidationError('workflow journal is empty.');
|
|
274
|
+
if (entries[0]?.kind !== 'workflow.run.started') {
|
|
275
|
+
throw new WorkflowJournalValidationError('workflow journal must start with workflow.run.started.');
|
|
276
|
+
}
|
|
277
|
+
const started = entries[0];
|
|
278
|
+
let previousEntryHash = ZERO_HASH;
|
|
279
|
+
let expectedAgentPreviousKey = ZERO_HASH;
|
|
280
|
+
let terminalSeen = false;
|
|
281
|
+
const startedAgents = new Map();
|
|
282
|
+
const startedAgentIndexes = new Set();
|
|
283
|
+
const finalizedAgents = new Set();
|
|
284
|
+
const agentCallKeys = new Set();
|
|
285
|
+
for (const [index, entry] of entries.entries()) {
|
|
286
|
+
if (entry.seq !== index + 1)
|
|
287
|
+
throw new WorkflowJournalValidationError(`workflow journal seq gap at ${entry.seq}.`);
|
|
288
|
+
if (entry.previousEntryHash !== previousEntryHash) {
|
|
289
|
+
throw new WorkflowJournalValidationError(`workflow journal hash chain mismatch at seq ${entry.seq}.`);
|
|
290
|
+
}
|
|
291
|
+
const { entryHash: _ignored, ...withoutEntryHash } = entry;
|
|
292
|
+
if (workflowJournalHash(withoutEntryHash) !== entry.entryHash) {
|
|
293
|
+
throw new WorkflowJournalValidationError(`workflow journal entryHash mismatch at seq ${entry.seq}.`);
|
|
294
|
+
}
|
|
295
|
+
if (entry.taskId !== started.taskId || entry.runId !== started.runId) {
|
|
296
|
+
throw new WorkflowJournalValidationError(`workflow journal task/run mismatch at seq ${entry.seq}.`);
|
|
297
|
+
}
|
|
298
|
+
if (terminalSeen) {
|
|
299
|
+
throw new WorkflowJournalValidationError(`workflow journal has non-terminal entry after terminal at seq ${entry.seq}.`);
|
|
300
|
+
}
|
|
301
|
+
if (entry.kind === 'workflow.agent.started') {
|
|
302
|
+
if (startedAgents.has(entry.agentId)) {
|
|
303
|
+
throw new WorkflowJournalValidationError(`duplicate agentId at seq ${entry.seq}.`);
|
|
304
|
+
}
|
|
305
|
+
if (startedAgentIndexes.has(entry.agentIndex)) {
|
|
306
|
+
throw new WorkflowJournalValidationError(`duplicate agentIndex at seq ${entry.seq}.`);
|
|
307
|
+
}
|
|
308
|
+
if (entry.previousAgentCallKey !== expectedAgentPreviousKey) {
|
|
309
|
+
throw new WorkflowJournalValidationError(`agent call key chain mismatch at seq ${entry.seq}.`);
|
|
310
|
+
}
|
|
311
|
+
const expectedAgentCallKey = computeWorkflowAgentCallKey({
|
|
312
|
+
previousAgentCallKey: entry.previousAgentCallKey,
|
|
313
|
+
prompt: entry.prompt,
|
|
314
|
+
semanticOpts: entry.semanticOpts,
|
|
315
|
+
});
|
|
316
|
+
if (entry.agentCallKey !== expectedAgentCallKey) {
|
|
317
|
+
throw new WorkflowJournalValidationError(`agent call key derivation mismatch at seq ${entry.seq}.`);
|
|
318
|
+
}
|
|
319
|
+
if (agentCallKeys.has(entry.agentCallKey)) {
|
|
320
|
+
throw new WorkflowJournalValidationError(`duplicate agentCallKey at seq ${entry.seq}.`);
|
|
321
|
+
}
|
|
322
|
+
agentCallKeys.add(entry.agentCallKey);
|
|
323
|
+
startedAgentIndexes.add(entry.agentIndex);
|
|
324
|
+
expectedAgentPreviousKey = entry.agentCallKey;
|
|
325
|
+
startedAgents.set(entry.agentId, entry);
|
|
326
|
+
}
|
|
327
|
+
else if (entry.kind === 'workflow.agent.completed' || entry.kind === 'workflow.agent.failed') {
|
|
328
|
+
const agent = startedAgents.get(entry.agentId);
|
|
329
|
+
if (!agent)
|
|
330
|
+
throw new WorkflowJournalValidationError(`agent final entry without started entry at seq ${entry.seq}.`);
|
|
331
|
+
if (finalizedAgents.has(entry.agentId))
|
|
332
|
+
throw new WorkflowJournalValidationError(`duplicate agent final entry at seq ${entry.seq}.`);
|
|
333
|
+
if (entry.agentIndex !== agent.agentIndex)
|
|
334
|
+
throw new WorkflowJournalValidationError(`agent final index mismatch at seq ${entry.seq}.`);
|
|
335
|
+
if (entry.agentCallKey !== agent.agentCallKey)
|
|
336
|
+
throw new WorkflowJournalValidationError(`agent final key mismatch at seq ${entry.seq}.`);
|
|
337
|
+
finalizedAgents.add(entry.agentId);
|
|
338
|
+
}
|
|
339
|
+
else if (entry.kind === 'workflow.run.completed' || entry.kind === 'workflow.run.failed') {
|
|
340
|
+
const openAgentCount = startedAgents.size - finalizedAgents.size;
|
|
341
|
+
if (openAgentCount > 0
|
|
342
|
+
&& (entry.kind === 'workflow.run.completed' || entry.reason !== WORKFLOW_JOURNAL_WRITE_FAILED_REASON)) {
|
|
343
|
+
throw new WorkflowJournalValidationError(`workflow journal terminal entry has ${openAgentCount} unfinalized agent(s) at seq ${entry.seq}.`);
|
|
344
|
+
}
|
|
345
|
+
terminalSeen = true;
|
|
346
|
+
}
|
|
347
|
+
previousEntryHash = entry.entryHash;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function assertValidWorkflowJournalEntry(value) {
|
|
351
|
+
const record = asRecord(value);
|
|
352
|
+
if (!record)
|
|
353
|
+
throw new WorkflowJournalValidationError('journal entry must be an object.');
|
|
354
|
+
if (record.version !== 1)
|
|
355
|
+
throw new WorkflowJournalValidationError('journal entry version must be 1.');
|
|
356
|
+
if (!isPositiveInteger(record.seq))
|
|
357
|
+
throw new WorkflowJournalValidationError('journal entry seq must be a positive integer.');
|
|
358
|
+
if (!isHash(record.previousEntryHash))
|
|
359
|
+
throw new WorkflowJournalValidationError('journal entry previousEntryHash must be sha256 hex.');
|
|
360
|
+
if (!isHash(record.entryHash))
|
|
361
|
+
throw new WorkflowJournalValidationError('journal entry entryHash must be sha256 hex.');
|
|
362
|
+
if (typeof record.recordedAt !== 'string' || !record.recordedAt)
|
|
363
|
+
throw new WorkflowJournalValidationError('journal entry recordedAt must be a string.');
|
|
364
|
+
if (typeof record.taskId !== 'string' || !record.taskId)
|
|
365
|
+
throw new WorkflowJournalValidationError('journal entry taskId must be a string.');
|
|
366
|
+
if (typeof record.runId !== 'string' || !record.runId)
|
|
367
|
+
throw new WorkflowJournalValidationError('journal entry runId must be a string.');
|
|
368
|
+
if (!isWorkflowJournalKind(record.kind))
|
|
369
|
+
throw new WorkflowJournalValidationError('journal entry kind is unknown.');
|
|
370
|
+
rejectUnknownEntryFields(record, record.kind);
|
|
371
|
+
assertNoOversizedStrings(record, 'entry');
|
|
372
|
+
switch (record.kind) {
|
|
373
|
+
case 'workflow.run.started':
|
|
374
|
+
assertRunStarted(record);
|
|
375
|
+
break;
|
|
376
|
+
case 'workflow.agent.started':
|
|
377
|
+
assertAgentStarted(record);
|
|
378
|
+
break;
|
|
379
|
+
case 'workflow.agent.completed':
|
|
380
|
+
assertAgentCompleted(record);
|
|
381
|
+
break;
|
|
382
|
+
case 'workflow.agent.failed':
|
|
383
|
+
assertAgentFailed(record);
|
|
384
|
+
break;
|
|
385
|
+
case 'workflow.run.completed':
|
|
386
|
+
assertRunCompleted(record);
|
|
387
|
+
break;
|
|
388
|
+
case 'workflow.run.failed':
|
|
389
|
+
assertRunFailed(record);
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function assertRunStarted(record) {
|
|
394
|
+
if (typeof record.workflowName !== 'string' || !record.workflowName)
|
|
395
|
+
throw new WorkflowJournalValidationError('workflowName must be a string.');
|
|
396
|
+
if (typeof record.workflowSource !== 'string' || !record.workflowSource)
|
|
397
|
+
throw new WorkflowJournalValidationError('workflowSource must be a string.');
|
|
398
|
+
if (record.workflowSourcePath !== undefined && typeof record.workflowSourcePath !== 'string')
|
|
399
|
+
throw new WorkflowJournalValidationError('workflowSourcePath must be a string.');
|
|
400
|
+
if (typeof record.scriptPath !== 'string' || !record.scriptPath)
|
|
401
|
+
throw new WorkflowJournalValidationError('scriptPath must be a string.');
|
|
402
|
+
if (typeof record.scriptHash !== 'string' || !record.scriptHash.startsWith('sha256:'))
|
|
403
|
+
throw new WorkflowJournalValidationError('scriptHash must start with sha256:.');
|
|
404
|
+
normalizeJournalJsonValue(record.args, 'args');
|
|
405
|
+
const runtime = asRecord(record.runtime);
|
|
406
|
+
if (!runtime || runtime.schemaVersion !== 1 || typeof runtime.cwd !== 'string') {
|
|
407
|
+
throw new WorkflowJournalValidationError('runtime must include schemaVersion 1 and cwd.');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function assertAgentStarted(record) {
|
|
411
|
+
assertAgentCommon(record);
|
|
412
|
+
if (!isHash(record.previousAgentCallKey))
|
|
413
|
+
throw new WorkflowJournalValidationError('previousAgentCallKey must be sha256 hex.');
|
|
414
|
+
if (typeof record.prompt !== 'string' || record.prompt.trim() === '')
|
|
415
|
+
throw new WorkflowJournalValidationError('prompt must be a non-empty string.');
|
|
416
|
+
assertWorkflowAgentSemanticOpts(record.semanticOpts);
|
|
417
|
+
}
|
|
418
|
+
function assertAgentCompleted(record) {
|
|
419
|
+
assertAgentCommon(record);
|
|
420
|
+
normalizeJournalJsonValue(record.result, 'result');
|
|
421
|
+
assertUsage(record.usage);
|
|
422
|
+
if (!isNonNegativeInteger(record.toolCalls))
|
|
423
|
+
throw new WorkflowJournalValidationError('toolCalls must be a non-negative integer.');
|
|
424
|
+
}
|
|
425
|
+
function assertAgentFailed(record) {
|
|
426
|
+
assertAgentCommon(record);
|
|
427
|
+
if (typeof record.reason !== 'string' || !record.reason)
|
|
428
|
+
throw new WorkflowJournalValidationError('reason must be a string.');
|
|
429
|
+
if (typeof record.message !== 'string')
|
|
430
|
+
throw new WorkflowJournalValidationError('message must be a string.');
|
|
431
|
+
}
|
|
432
|
+
function assertRunCompleted(record) {
|
|
433
|
+
normalizeJournalJsonValue(record.result, 'result');
|
|
434
|
+
if (typeof record.resultPath !== 'string' || !record.resultPath)
|
|
435
|
+
throw new WorkflowJournalValidationError('resultPath must be a string.');
|
|
436
|
+
if (!isNonNegativeInteger(record.agentCount))
|
|
437
|
+
throw new WorkflowJournalValidationError('agentCount must be a non-negative integer.');
|
|
438
|
+
assertUsage(record.usage);
|
|
439
|
+
if (!isNonNegativeInteger(record.toolCalls))
|
|
440
|
+
throw new WorkflowJournalValidationError('toolCalls must be a non-negative integer.');
|
|
441
|
+
if (!isNonNegativeNumber(record.durationMs))
|
|
442
|
+
throw new WorkflowJournalValidationError('durationMs must be a non-negative finite number.');
|
|
443
|
+
}
|
|
444
|
+
function assertRunFailed(record) {
|
|
445
|
+
if (typeof record.reason !== 'string' || !record.reason)
|
|
446
|
+
throw new WorkflowJournalValidationError('reason must be a string.');
|
|
447
|
+
if (typeof record.message !== 'string')
|
|
448
|
+
throw new WorkflowJournalValidationError('message must be a string.');
|
|
449
|
+
if (record.recovery !== undefined) {
|
|
450
|
+
const recovery = asRecord(record.recovery);
|
|
451
|
+
if (!recovery || typeof recovery.retryable !== 'boolean' || typeof recovery.reason !== 'string') {
|
|
452
|
+
throw new WorkflowJournalValidationError('recovery must include retryable and reason.');
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (!isNonNegativeNumber(record.durationMs))
|
|
456
|
+
throw new WorkflowJournalValidationError('durationMs must be a non-negative finite number.');
|
|
457
|
+
}
|
|
458
|
+
function assertAgentCommon(record) {
|
|
459
|
+
if (!isNonNegativeInteger(record.agentIndex))
|
|
460
|
+
throw new WorkflowJournalValidationError('agentIndex must be a non-negative integer.');
|
|
461
|
+
if (typeof record.agentId !== 'string' || !record.agentId)
|
|
462
|
+
throw new WorkflowJournalValidationError('agentId must be a string.');
|
|
463
|
+
if (!isHash(record.agentCallKey))
|
|
464
|
+
throw new WorkflowJournalValidationError('agentCallKey must be sha256 hex.');
|
|
465
|
+
}
|
|
466
|
+
function assertUsage(value) {
|
|
467
|
+
const usage = asRecord(value);
|
|
468
|
+
if (!usage)
|
|
469
|
+
throw new WorkflowJournalValidationError('usage must be an object.');
|
|
470
|
+
for (const key of ['inputTokens', 'outputTokens', 'totalTokens']) {
|
|
471
|
+
if (!isNonNegativeNumber(usage[key]))
|
|
472
|
+
throw new WorkflowJournalValidationError(`usage.${key} must be a non-negative finite number.`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function assertWorkflowAgentSemanticOpts(value) {
|
|
476
|
+
const opts = asRecord(value);
|
|
477
|
+
if (!opts)
|
|
478
|
+
throw new WorkflowJournalValidationError('semanticOpts must be an object.');
|
|
479
|
+
rejectUnknownKeys(opts, ['schema', 'model', 'effort', 'isolation', 'agentType'], 'semanticOpts');
|
|
480
|
+
if (typeof opts.model !== 'string' || !opts.model)
|
|
481
|
+
throw new WorkflowJournalValidationError('semanticOpts.model must be a string.');
|
|
482
|
+
for (const key of ['effort', 'isolation', 'agentType']) {
|
|
483
|
+
if (opts[key] !== undefined && typeof opts[key] !== 'string') {
|
|
484
|
+
throw new WorkflowJournalValidationError(`semanticOpts.${key} must be a string.`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (opts.schema !== undefined)
|
|
488
|
+
normalizeJournalJsonValue(opts.schema, 'semanticOpts.schema');
|
|
489
|
+
}
|
|
490
|
+
function rejectUnknownEntryFields(record, kind) {
|
|
491
|
+
rejectUnknownKeys(record, ENTRY_KEYS[kind], kind);
|
|
492
|
+
}
|
|
493
|
+
function rejectUnknownKeys(record, allowed, label) {
|
|
494
|
+
const allowedSet = new Set(allowed);
|
|
495
|
+
for (const key of Object.keys(record)) {
|
|
496
|
+
if (!allowedSet.has(key))
|
|
497
|
+
throw new WorkflowJournalValidationError(`${label} contains unknown field ${key}.`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function assertNoOversizedStrings(value, path) {
|
|
501
|
+
if (typeof value === 'string') {
|
|
502
|
+
if (Buffer.byteLength(value, 'utf8') > MAX_STRING_BYTES) {
|
|
503
|
+
throw new WorkflowJournalValidationError(`${path} exceeds ${MAX_STRING_BYTES} bytes.`);
|
|
504
|
+
}
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (!value || typeof value !== 'object')
|
|
508
|
+
return;
|
|
509
|
+
if (Array.isArray(value)) {
|
|
510
|
+
for (const [index, item] of value.entries())
|
|
511
|
+
assertNoOversizedStrings(item, `${path}[${index}]`);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
for (const [key, item] of Object.entries(value))
|
|
515
|
+
assertNoOversizedStrings(item, `${path}.${key}`);
|
|
516
|
+
}
|
|
517
|
+
function normalizeJsonValue(value, label, seen) {
|
|
518
|
+
if (value === null)
|
|
519
|
+
return null;
|
|
520
|
+
if (typeof value === 'string')
|
|
521
|
+
return value;
|
|
522
|
+
if (typeof value === 'boolean')
|
|
523
|
+
return value;
|
|
524
|
+
if (typeof value === 'number') {
|
|
525
|
+
if (!Number.isFinite(value))
|
|
526
|
+
throw new WorkflowJournalValidationError(`${label} must not contain NaN or Infinity.`);
|
|
527
|
+
return value;
|
|
528
|
+
}
|
|
529
|
+
if (value === undefined || typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') {
|
|
530
|
+
throw new WorkflowJournalValidationError(`${label} must be JSON-serializable.`);
|
|
531
|
+
}
|
|
532
|
+
if (typeof value !== 'object')
|
|
533
|
+
throw new WorkflowJournalValidationError(`${label} must be JSON-serializable.`);
|
|
534
|
+
if (seen.has(value))
|
|
535
|
+
throw new WorkflowJournalValidationError(`${label} must not contain cycles.`);
|
|
536
|
+
seen.add(value);
|
|
537
|
+
try {
|
|
538
|
+
if (Array.isArray(value)) {
|
|
539
|
+
return value.map((item, index) => normalizeJsonValue(item, `${label}[${index}]`, seen));
|
|
540
|
+
}
|
|
541
|
+
if (!isPlainJsonObject(value)) {
|
|
542
|
+
throw new WorkflowJournalValidationError(`${label} must be a plain JSON object.`);
|
|
543
|
+
}
|
|
544
|
+
const out = {};
|
|
545
|
+
for (const [key, item] of Object.entries(value)) {
|
|
546
|
+
out[key] = normalizeJsonValue(item, `${label}.${key}`, seen);
|
|
547
|
+
}
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
finally {
|
|
551
|
+
seen.delete(value);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function stableSerialize(value) {
|
|
555
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
|
|
556
|
+
return JSON.stringify(value);
|
|
557
|
+
}
|
|
558
|
+
if (Array.isArray(value)) {
|
|
559
|
+
return `[${value.map((item) => stableSerialize(item)).join(',')}]`;
|
|
560
|
+
}
|
|
561
|
+
const objectValue = value;
|
|
562
|
+
const keys = Object.keys(objectValue).sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
563
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableSerialize(objectValue[key])}`).join(',')}}`;
|
|
564
|
+
}
|
|
565
|
+
function toWorkflowJournalError(err, message) {
|
|
566
|
+
return err instanceof WorkflowJournalError ? err : new WorkflowJournalError(message, err);
|
|
567
|
+
}
|
|
568
|
+
function isPlainJsonObject(value) {
|
|
569
|
+
if (Object.prototype.toString.call(value) !== '[object Object]')
|
|
570
|
+
return false;
|
|
571
|
+
const prototype = Object.getPrototypeOf(value);
|
|
572
|
+
if (prototype === null)
|
|
573
|
+
return true;
|
|
574
|
+
return Object.getPrototypeOf(prototype) === null;
|
|
575
|
+
}
|
|
576
|
+
async function ensureWorkflowTranscriptDir(transcriptDir) {
|
|
577
|
+
let before;
|
|
578
|
+
try {
|
|
579
|
+
before = await lstat(transcriptDir);
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
const code = err.code;
|
|
583
|
+
if (code !== 'ENOENT')
|
|
584
|
+
throw new WorkflowJournalError(`Workflow transcript directory cannot be inspected: ${transcriptDir}.`, err);
|
|
585
|
+
}
|
|
586
|
+
if (before?.isSymbolicLink())
|
|
587
|
+
throw new WorkflowJournalError(`Workflow transcript directory must not be a symlink: ${transcriptDir}.`);
|
|
588
|
+
if (before && !before.isDirectory())
|
|
589
|
+
throw new WorkflowJournalError(`Workflow transcript path must be a directory: ${transcriptDir}.`);
|
|
590
|
+
if (!before) {
|
|
591
|
+
try {
|
|
592
|
+
await mkdir(transcriptDir, { recursive: true, mode: TRANSCRIPT_DIR_MODE });
|
|
593
|
+
}
|
|
594
|
+
catch (err) {
|
|
595
|
+
throw new WorkflowJournalError(`Workflow transcript directory cannot be created: ${transcriptDir}.`, err);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const after = await lstat(transcriptDir);
|
|
599
|
+
if (after.isSymbolicLink() || !after.isDirectory()) {
|
|
600
|
+
throw new WorkflowJournalError(`Workflow transcript path must be a real directory: ${transcriptDir}.`);
|
|
601
|
+
}
|
|
602
|
+
await chmod(transcriptDir, TRANSCRIPT_DIR_MODE).catch(() => undefined);
|
|
603
|
+
}
|
|
604
|
+
async function appendJournalLine(journalPath, entry, durability) {
|
|
605
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
606
|
+
let handle;
|
|
607
|
+
let beforeSize;
|
|
608
|
+
try {
|
|
609
|
+
handle = await open(journalPath, fsConstants.O_APPEND | fsConstants.O_WRONLY, JOURNAL_FILE_MODE);
|
|
610
|
+
beforeSize = (await handle.stat()).size;
|
|
611
|
+
await handle.writeFile(line, 'utf8');
|
|
612
|
+
if (durability?.syncFile) {
|
|
613
|
+
await durability.syncFile(journalPath, entry);
|
|
614
|
+
}
|
|
615
|
+
else if (typeof handle.datasync === 'function') {
|
|
616
|
+
await handle.datasync();
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
await handle.sync();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
if (handle && beforeSize !== undefined) {
|
|
624
|
+
await handle.truncate(beforeSize).catch(() => undefined);
|
|
625
|
+
await handle.sync().catch(() => undefined);
|
|
626
|
+
}
|
|
627
|
+
throw new WorkflowJournalError(`Workflow journal entry ${entry.kind} could not be durably written.`, err);
|
|
628
|
+
}
|
|
629
|
+
finally {
|
|
630
|
+
await handle?.close().catch(() => undefined);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async function durableDirectorySync(directoryPath, durability) {
|
|
634
|
+
if (durability?.syncDirectory) {
|
|
635
|
+
await durability.syncDirectory(directoryPath);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
let handle;
|
|
639
|
+
try {
|
|
640
|
+
await stat(directoryPath);
|
|
641
|
+
handle = await open(directoryPath, fsConstants.O_RDONLY);
|
|
642
|
+
await handle.sync();
|
|
643
|
+
}
|
|
644
|
+
catch (err) {
|
|
645
|
+
if (isDirectorySyncUnsupported(err))
|
|
646
|
+
return;
|
|
647
|
+
throw new WorkflowJournalError(`Workflow journal directory could not be durably synced: ${directoryPath}.`, err);
|
|
648
|
+
}
|
|
649
|
+
finally {
|
|
650
|
+
await handle?.close().catch(() => undefined);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
function isDirectorySyncUnsupported(err) {
|
|
654
|
+
const code = err.code;
|
|
655
|
+
return code === 'EINVAL' || code === 'EISDIR' || code === 'ENOTSUP' || code === 'EPERM' || code === 'EACCES';
|
|
656
|
+
}
|
|
657
|
+
function isTruncationParseError(err) {
|
|
658
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
659
|
+
return /Unexpected end|unterminated|end of JSON input|Expected ',' or '}' after property value/i.test(message);
|
|
660
|
+
}
|
|
661
|
+
function isWorkflowJournalKind(value) {
|
|
662
|
+
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(ENTRY_KEYS, value);
|
|
663
|
+
}
|
|
664
|
+
function isHash(value) {
|
|
665
|
+
return typeof value === 'string' && HASH_RE.test(value);
|
|
666
|
+
}
|
|
667
|
+
function isPositiveInteger(value) {
|
|
668
|
+
return Number.isInteger(value) && value > 0;
|
|
669
|
+
}
|
|
670
|
+
function isNonNegativeInteger(value) {
|
|
671
|
+
return Number.isInteger(value) && value >= 0;
|
|
672
|
+
}
|
|
673
|
+
function isNonNegativeNumber(value) {
|
|
674
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
675
|
+
}
|
|
676
|
+
function asRecord(value) {
|
|
677
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
678
|
+
}
|
|
679
|
+
function sha256(value) {
|
|
680
|
+
return createHash('sha256').update(value).digest('hex');
|
|
681
|
+
}
|