@linzumi/cli 1.0.18 → 1.0.19
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 +1 -1
- package/dist/index.js +330 -319
- package/dist/mcp-server.mjs +4 -4
- package/package.json +12 -3
- package/scripts/normalize-agent-replay-fixture.ts +281 -0
- package/scripts/promote-gstack-claude-smoke-fixture.ts +891 -0
- package/scripts/record-agent-replay-fixtures.ts +580 -0
- package/scripts/replay-agent-backend-browser.ts +198 -0
- package/scripts/replay-agent-backend.ts +1782 -0
- package/scripts/replay-agent-fixture.ts +325 -0
- package/scripts/vendor-codex-app-server-protocol.ts +383 -0
|
@@ -0,0 +1,1782 @@
|
|
|
1
|
+
/*
|
|
2
|
+
- Date: 2026-06-29
|
|
3
|
+
Spec: plans/2026-06-29-agent-backend-replay-orchestrator-spec.md
|
|
4
|
+
Relationship: First executable backend replay orchestrator slice. It validates
|
|
5
|
+
real provider fixtures, manifests, provider selection, replay timing, and
|
|
6
|
+
dropped-event diagnostics before a fixture is allowed to drive the real
|
|
7
|
+
Commander-to-Phoenix backend path.
|
|
8
|
+
|
|
9
|
+
- Date: 2026-06-30
|
|
10
|
+
Spec: plans/2026-06-30-gstack-agent-timeline-event-model-plan.md
|
|
11
|
+
Relationship: Queries generic agent status/task timeline rows and includes
|
|
12
|
+
them in the backend replay oracle output set.
|
|
13
|
+
*/
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
import { collectAgentReplayDiagnostics } from '../src/agentReplayDiagnostics';
|
|
18
|
+
import { featureBackendReplayOracleChecks } from '../src/agentReplayBackendOracles';
|
|
19
|
+
import { loadAgentReplayFixture } from '../src/agentReplayFixture';
|
|
20
|
+
import {
|
|
21
|
+
evaluateAgentReplayFeatureReadiness,
|
|
22
|
+
loadAgentReplayManifestForFixture,
|
|
23
|
+
summarizeAgentReplayManifest,
|
|
24
|
+
type AgentReplayFeatureReadiness,
|
|
25
|
+
type AgentReplayManifestSummary,
|
|
26
|
+
} from '../src/agentReplayManifest';
|
|
27
|
+
import {
|
|
28
|
+
agentReplayProviderSelectionFromValue,
|
|
29
|
+
createAgentReplayRuntime,
|
|
30
|
+
parseAgentReplayClockMode,
|
|
31
|
+
type AgentReplayRunnerProviderSelection,
|
|
32
|
+
} from '../src/agentReplayRunner';
|
|
33
|
+
import { connectPhoenixClient } from '../src/phoenix';
|
|
34
|
+
import { type JsonObject } from '../src/protocol';
|
|
35
|
+
import { runLocalCodexRunner, type RunnerOptions } from '../src/runner';
|
|
36
|
+
import { linzumiCliVersion } from '../src/version';
|
|
37
|
+
|
|
38
|
+
const backendReplayOracleTimeoutMs = 30_000;
|
|
39
|
+
const backendReplayOraclePollMs = 100;
|
|
40
|
+
|
|
41
|
+
type BackendReplayOptions = {
|
|
42
|
+
readonly fixturePath: string;
|
|
43
|
+
readonly provider: AgentReplayRunnerProviderSelection;
|
|
44
|
+
readonly speed: string | undefined;
|
|
45
|
+
readonly dryRun: boolean;
|
|
46
|
+
readonly probeBackend: boolean;
|
|
47
|
+
readonly backendUrl: string | undefined;
|
|
48
|
+
readonly workspace: string | undefined;
|
|
49
|
+
readonly channel: string | undefined;
|
|
50
|
+
readonly threadId: string | undefined;
|
|
51
|
+
readonly token: string | undefined;
|
|
52
|
+
readonly sessionToken: string | undefined;
|
|
53
|
+
readonly runnerId: string;
|
|
54
|
+
readonly cwd: string;
|
|
55
|
+
readonly workDescription: string;
|
|
56
|
+
readonly setupFixture: boolean;
|
|
57
|
+
readonly setupLabel: string;
|
|
58
|
+
readonly outDir: string;
|
|
59
|
+
readonly summary: boolean;
|
|
60
|
+
readonly browserAuthFile: string | undefined;
|
|
61
|
+
readonly browserAuth: BackendReplayBrowserAuth | undefined;
|
|
62
|
+
readonly requiredFeatureId: string | undefined;
|
|
63
|
+
readonly oracleFeatureId: string | undefined;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type BackendReplayBrowserAuth = {
|
|
67
|
+
readonly accessToken: string;
|
|
68
|
+
readonly refreshToken: string | undefined;
|
|
69
|
+
readonly userId: string | number | undefined;
|
|
70
|
+
readonly username: string;
|
|
71
|
+
readonly user: JsonObject;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type BackendReplaySummaryArgs = {
|
|
75
|
+
readonly options: BackendReplayOptions;
|
|
76
|
+
readonly fixturePath: string;
|
|
77
|
+
readonly recordCount: number;
|
|
78
|
+
readonly provider: string;
|
|
79
|
+
readonly clockMode: ReturnType<typeof parseAgentReplayClockMode>;
|
|
80
|
+
readonly manifest: AgentReplayManifestSummary | undefined;
|
|
81
|
+
readonly featureReadiness: readonly AgentReplayFeatureReadiness[] | undefined;
|
|
82
|
+
readonly diagnostics: ReturnType<typeof collectAgentReplayDiagnostics>;
|
|
83
|
+
readonly backendReplay: JsonObject;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
async function main(): Promise<void> {
|
|
87
|
+
const args = process.argv.slice(2);
|
|
88
|
+
|
|
89
|
+
if (args.includes('--help')) {
|
|
90
|
+
printHelp();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let options = parseArgs(args);
|
|
95
|
+
const clockMode = parseAgentReplayClockMode(options.speed);
|
|
96
|
+
const loaded = await loadAgentReplayFixture(options.fixturePath);
|
|
97
|
+
|
|
98
|
+
if (loaded.type === 'error') {
|
|
99
|
+
throw new Error(JSON.stringify(loaded.error));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const runtime = await createAgentReplayRuntime({
|
|
103
|
+
fixturePath: options.fixturePath,
|
|
104
|
+
provider: options.provider,
|
|
105
|
+
clockMode,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (runtime.type === 'error') {
|
|
109
|
+
throw new Error(runtime.message);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const manifest = await manifestSummary(options.fixturePath);
|
|
113
|
+
const featureReadiness = await manifestReadiness(options.fixturePath);
|
|
114
|
+
const diagnostics = collectAgentReplayDiagnostics(loaded.fixture);
|
|
115
|
+
const requiredFeatureGate = evaluateRequiredFeatureGate(
|
|
116
|
+
options.requiredFeatureId,
|
|
117
|
+
featureReadiness
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
if (diagnostics.droppedEvents.length > 0) {
|
|
121
|
+
const summary = backendReplaySummary({
|
|
122
|
+
options,
|
|
123
|
+
fixturePath: loaded.fixture.path,
|
|
124
|
+
recordCount: loaded.fixture.records.length,
|
|
125
|
+
provider: runtime.runtime.provider,
|
|
126
|
+
clockMode,
|
|
127
|
+
manifest,
|
|
128
|
+
featureReadiness,
|
|
129
|
+
diagnostics,
|
|
130
|
+
backendReplay: {
|
|
131
|
+
status: 'blocked',
|
|
132
|
+
reason: 'fixture diagnostics reported dropped provider events',
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
await emitBackendReplaySummary(options, summary);
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (requiredFeatureGate.status === 'failed') {
|
|
141
|
+
const summary = backendReplaySummary({
|
|
142
|
+
options,
|
|
143
|
+
fixturePath: loaded.fixture.path,
|
|
144
|
+
recordCount: loaded.fixture.records.length,
|
|
145
|
+
provider: runtime.runtime.provider,
|
|
146
|
+
clockMode,
|
|
147
|
+
manifest,
|
|
148
|
+
featureReadiness,
|
|
149
|
+
diagnostics,
|
|
150
|
+
backendReplay: {
|
|
151
|
+
status: 'blocked',
|
|
152
|
+
reason: 'required feature is not ready for backend replay',
|
|
153
|
+
requiredFeature: requiredFeatureGate,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
await emitBackendReplaySummary(options, summary);
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!options.dryRun || options.probeBackend) {
|
|
162
|
+
options = await maybeSetupBackendReplayFixture(options);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await maybeWriteBackendReplayBrowserAuth(options);
|
|
166
|
+
|
|
167
|
+
if (options.probeBackend) {
|
|
168
|
+
const probe = await probeBackend(options);
|
|
169
|
+
const summary = backendReplaySummary({
|
|
170
|
+
options,
|
|
171
|
+
fixturePath: loaded.fixture.path,
|
|
172
|
+
recordCount: loaded.fixture.records.length,
|
|
173
|
+
provider: runtime.runtime.provider,
|
|
174
|
+
clockMode,
|
|
175
|
+
manifest,
|
|
176
|
+
featureReadiness,
|
|
177
|
+
diagnostics,
|
|
178
|
+
backendReplay: {
|
|
179
|
+
status: 'backend-probed',
|
|
180
|
+
reason:
|
|
181
|
+
'joined the real Phoenix local_runner channel; replay/oracles are the next slice',
|
|
182
|
+
probe,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
await emitBackendReplaySummary(options, summary);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (options.dryRun) {
|
|
190
|
+
const summary = backendReplaySummary({
|
|
191
|
+
options,
|
|
192
|
+
fixturePath: loaded.fixture.path,
|
|
193
|
+
recordCount: loaded.fixture.records.length,
|
|
194
|
+
provider: runtime.runtime.provider,
|
|
195
|
+
clockMode,
|
|
196
|
+
manifest,
|
|
197
|
+
featureReadiness,
|
|
198
|
+
diagnostics,
|
|
199
|
+
backendReplay: {
|
|
200
|
+
status: 'not-run',
|
|
201
|
+
reason: 'dry-run validates fixture readiness before backend connection',
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await emitBackendReplaySummary(options, summary);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const replay = await runBackendReplay(options, runtime.runtime.provider);
|
|
210
|
+
const summary = backendReplaySummary({
|
|
211
|
+
options,
|
|
212
|
+
fixturePath: loaded.fixture.path,
|
|
213
|
+
recordCount: loaded.fixture.records.length,
|
|
214
|
+
provider: runtime.runtime.provider,
|
|
215
|
+
clockMode,
|
|
216
|
+
manifest,
|
|
217
|
+
featureReadiness,
|
|
218
|
+
diagnostics,
|
|
219
|
+
backendReplay: {
|
|
220
|
+
status: 'backend-replay-started',
|
|
221
|
+
reason:
|
|
222
|
+
'started the real local runner with provider replay, triggered the normal GraphQL start mutation, and ran backend GraphQL oracles',
|
|
223
|
+
replay,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await emitBackendReplaySummary(options, summary);
|
|
228
|
+
|
|
229
|
+
if (backendReplayOracleStatus(replay) === 'failed') {
|
|
230
|
+
process.exitCode = 1;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function emitBackendReplaySummary(
|
|
235
|
+
options: BackendReplayOptions,
|
|
236
|
+
summary: JsonObject
|
|
237
|
+
): Promise<void> {
|
|
238
|
+
if (options.summary) {
|
|
239
|
+
await mkdir(options.outDir, { recursive: true });
|
|
240
|
+
await writeFile(
|
|
241
|
+
join(options.outDir, 'summary.json'),
|
|
242
|
+
`${JSON.stringify(summary, null, 2)}\n`,
|
|
243
|
+
'utf8'
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function frontendReplayTarget(
|
|
251
|
+
options: BackendReplayOptions,
|
|
252
|
+
threadId: string | undefined
|
|
253
|
+
): JsonObject | undefined {
|
|
254
|
+
if (
|
|
255
|
+
options.backendUrl === undefined ||
|
|
256
|
+
options.workspace === undefined ||
|
|
257
|
+
options.channel === undefined ||
|
|
258
|
+
threadId === undefined
|
|
259
|
+
) {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const url = new URL(options.backendUrl);
|
|
264
|
+
|
|
265
|
+
switch (url.protocol) {
|
|
266
|
+
case 'http:':
|
|
267
|
+
case 'https:':
|
|
268
|
+
break;
|
|
269
|
+
case 'ws:':
|
|
270
|
+
url.protocol = 'http:';
|
|
271
|
+
break;
|
|
272
|
+
case 'wss:':
|
|
273
|
+
url.protocol = 'https:';
|
|
274
|
+
break;
|
|
275
|
+
default:
|
|
276
|
+
throw new Error(
|
|
277
|
+
'--backend-url must use http:, https:, ws:, or wss: protocol'
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const path = `/w/${encodeURIComponent(options.workspace)}/c/${encodeURIComponent(
|
|
282
|
+
options.channel
|
|
283
|
+
)}/thread/${encodeURIComponent(threadId)}`;
|
|
284
|
+
url.pathname = path;
|
|
285
|
+
url.search = '';
|
|
286
|
+
url.hash = '';
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
path,
|
|
290
|
+
url: url.toString(),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function backendReplayOracleStatus(replay: JsonObject): string | undefined {
|
|
295
|
+
const oracles = replay.oracles;
|
|
296
|
+
|
|
297
|
+
if (
|
|
298
|
+
typeof oracles === 'object' &&
|
|
299
|
+
oracles !== null &&
|
|
300
|
+
!Array.isArray(oracles) &&
|
|
301
|
+
typeof oracles.status === 'string'
|
|
302
|
+
) {
|
|
303
|
+
return oracles.status;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function parseArgs(args: readonly string[]): BackendReplayOptions {
|
|
310
|
+
const fixture = argValue(args, '--fixture');
|
|
311
|
+
|
|
312
|
+
if (fixture === undefined) {
|
|
313
|
+
throw new Error('provide --fixture <path>');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
fixturePath: resolve(fixture),
|
|
318
|
+
provider: agentReplayProviderSelectionFromValue(
|
|
319
|
+
argValue(args, '--provider')
|
|
320
|
+
),
|
|
321
|
+
speed: argValue(args, '--speed'),
|
|
322
|
+
dryRun: args.includes('--dry-run'),
|
|
323
|
+
probeBackend: args.includes('--probe-backend'),
|
|
324
|
+
backendUrl: argValue(args, '--backend-url'),
|
|
325
|
+
workspace: argValue(args, '--workspace'),
|
|
326
|
+
channel: argValue(args, '--channel'),
|
|
327
|
+
threadId: argValue(args, '--thread-id'),
|
|
328
|
+
token:
|
|
329
|
+
argValue(args, '--token') ??
|
|
330
|
+
process.env.LINZUMI_AGENT_BACKEND_REPLAY_TOKEN,
|
|
331
|
+
sessionToken:
|
|
332
|
+
argValue(args, '--session-token') ??
|
|
333
|
+
process.env.LINZUMI_AGENT_BACKEND_REPLAY_SESSION_TOKEN,
|
|
334
|
+
runnerId:
|
|
335
|
+
argValue(args, '--runner-id') ?? `agent-backend-replay-${randomUUID()}`,
|
|
336
|
+
cwd: resolve(argValue(args, '--cwd') ?? process.cwd()),
|
|
337
|
+
workDescription:
|
|
338
|
+
argValue(args, '--work-description') ??
|
|
339
|
+
'Replay recorded agent fixture through the local backend path.',
|
|
340
|
+
setupFixture: args.includes('--setup-fixture'),
|
|
341
|
+
setupLabel: argValue(args, '--setup-label') ?? 'agent-backend-replay',
|
|
342
|
+
outDir: resolve(
|
|
343
|
+
argValue(args, '--out-dir') ?? 'agent-backend-replay-output'
|
|
344
|
+
),
|
|
345
|
+
summary: !args.includes('--no-summary'),
|
|
346
|
+
browserAuthFile:
|
|
347
|
+
argValue(args, '--browser-auth-file') === undefined
|
|
348
|
+
? undefined
|
|
349
|
+
: resolve(argValue(args, '--browser-auth-file') ?? ''),
|
|
350
|
+
browserAuth: undefined,
|
|
351
|
+
requiredFeatureId: argValue(args, '--require-feature'),
|
|
352
|
+
oracleFeatureId: argValue(args, '--oracle-feature'),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function backendReplaySummary(args: BackendReplaySummaryArgs): JsonObject {
|
|
357
|
+
return {
|
|
358
|
+
mode: args.options.probeBackend
|
|
359
|
+
? 'backend-probe'
|
|
360
|
+
: args.options.dryRun
|
|
361
|
+
? 'dry-run'
|
|
362
|
+
: 'backend-replay',
|
|
363
|
+
fixture: {
|
|
364
|
+
path: args.fixturePath,
|
|
365
|
+
records: args.recordCount,
|
|
366
|
+
},
|
|
367
|
+
provider: args.provider,
|
|
368
|
+
clockMode: args.clockMode,
|
|
369
|
+
backendTarget: {
|
|
370
|
+
backendUrl: args.options.backendUrl,
|
|
371
|
+
workspace: args.options.workspace,
|
|
372
|
+
channel: args.options.channel,
|
|
373
|
+
threadId: args.options.threadId,
|
|
374
|
+
runnerId: args.options.runnerId,
|
|
375
|
+
hasToken: args.options.token !== undefined,
|
|
376
|
+
hasSessionToken: args.options.sessionToken !== undefined,
|
|
377
|
+
setupFixture: args.options.setupFixture,
|
|
378
|
+
setupLabel: args.options.setupFixture
|
|
379
|
+
? args.options.setupLabel
|
|
380
|
+
: undefined,
|
|
381
|
+
},
|
|
382
|
+
artifacts: {
|
|
383
|
+
summaryPath: args.options.summary
|
|
384
|
+
? join(args.options.outDir, 'summary.json')
|
|
385
|
+
: undefined,
|
|
386
|
+
browserAuthPath: args.options.browserAuthFile,
|
|
387
|
+
},
|
|
388
|
+
requiredFeatureId: args.options.requiredFeatureId,
|
|
389
|
+
oracleFeatureId: args.options.oracleFeatureId,
|
|
390
|
+
frontendTarget: frontendReplayTarget(args.options, args.options.threadId),
|
|
391
|
+
featureCoverage: args.manifest,
|
|
392
|
+
featureReadiness: args.featureReadiness,
|
|
393
|
+
diagnostics: args.diagnostics,
|
|
394
|
+
backendReplay: args.backendReplay,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function maybeSetupBackendReplayFixture(
|
|
399
|
+
options: BackendReplayOptions
|
|
400
|
+
): Promise<BackendReplayOptions> {
|
|
401
|
+
if (!options.setupFixture) {
|
|
402
|
+
return options;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (options.backendUrl === undefined) {
|
|
406
|
+
throw new Error('--setup-fixture requires --backend-url <url>');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const suffix = `${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
410
|
+
const workspace = slugifyReplaySetup(
|
|
411
|
+
`${options.setupLabel}-workspace-${suffix}`
|
|
412
|
+
);
|
|
413
|
+
const channel = slugifyReplaySetup(`${options.setupLabel}-channel-${suffix}`);
|
|
414
|
+
const session = await registerReplayFixtureUser(
|
|
415
|
+
options.backendUrl,
|
|
416
|
+
`${options.setupLabel}-${suffix}`
|
|
417
|
+
);
|
|
418
|
+
await enableReplayFixtureFeatureFlag(
|
|
419
|
+
options.backendUrl,
|
|
420
|
+
session.accessToken,
|
|
421
|
+
'CLAUDE_CODE_SUPPORT_PREVIEW'
|
|
422
|
+
);
|
|
423
|
+
await createReplayWorkspace(options.backendUrl, session.accessToken, {
|
|
424
|
+
name: `${options.setupLabel} workspace ${suffix}`,
|
|
425
|
+
slug: workspace,
|
|
426
|
+
});
|
|
427
|
+
await createReplayChannel(options.backendUrl, session.accessToken, {
|
|
428
|
+
workspace,
|
|
429
|
+
name: `${options.setupLabel} channel ${suffix}`,
|
|
430
|
+
slug: channel,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
...options,
|
|
435
|
+
workspace,
|
|
436
|
+
channel,
|
|
437
|
+
sessionToken: session.accessToken,
|
|
438
|
+
browserAuth: session,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function maybeWriteBackendReplayBrowserAuth(
|
|
443
|
+
options: BackendReplayOptions
|
|
444
|
+
): Promise<void> {
|
|
445
|
+
if (options.browserAuthFile === undefined) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const auth =
|
|
450
|
+
options.browserAuth ??
|
|
451
|
+
(options.sessionToken === undefined
|
|
452
|
+
? undefined
|
|
453
|
+
: {
|
|
454
|
+
accessToken: options.sessionToken,
|
|
455
|
+
refreshToken: process.env.LINZUMI_AGENT_BACKEND_REPLAY_REFRESH_TOKEN,
|
|
456
|
+
userId: 'agent-backend-replay',
|
|
457
|
+
username: 'agent-backend-replay',
|
|
458
|
+
user: {
|
|
459
|
+
id: 'agent-backend-replay',
|
|
460
|
+
username: 'agent-backend-replay',
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
if (auth === undefined) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
'--browser-auth-file requires --setup-fixture, --session-token, or LINZUMI_AGENT_BACKEND_REPLAY_SESSION_TOKEN'
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
await mkdir(resolve(options.browserAuthFile, '..'), { recursive: true });
|
|
471
|
+
await writeFile(
|
|
472
|
+
options.browserAuthFile,
|
|
473
|
+
`${JSON.stringify(auth, null, 2)}\n`,
|
|
474
|
+
'utf8'
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
type ReplayFixtureSession = BackendReplayBrowserAuth;
|
|
479
|
+
|
|
480
|
+
async function registerReplayFixtureUser(
|
|
481
|
+
backendUrl: string,
|
|
482
|
+
label: string
|
|
483
|
+
): Promise<ReplayFixtureSession> {
|
|
484
|
+
const username = slugifyReplaySetup(label);
|
|
485
|
+
const response = await backendFetch(
|
|
486
|
+
backendHttpUrl(backendUrl, '/api/v2/auth/register'),
|
|
487
|
+
{
|
|
488
|
+
method: 'POST',
|
|
489
|
+
headers: { 'content-type': 'application/json' },
|
|
490
|
+
body: JSON.stringify({
|
|
491
|
+
display_name: username,
|
|
492
|
+
e2e_fixture: true,
|
|
493
|
+
email: `${username}@example.test`,
|
|
494
|
+
password: `pw-${username}`,
|
|
495
|
+
username,
|
|
496
|
+
}),
|
|
497
|
+
},
|
|
498
|
+
'fixture user registration'
|
|
499
|
+
);
|
|
500
|
+
const payload = await parseJsonObjectResponse(
|
|
501
|
+
response,
|
|
502
|
+
'fixture user registration'
|
|
503
|
+
);
|
|
504
|
+
const accessToken = (payload as { access_token?: unknown }).access_token;
|
|
505
|
+
const refreshToken = (payload as { refresh_token?: unknown }).refresh_token;
|
|
506
|
+
const user = (payload as { user?: unknown }).user;
|
|
507
|
+
const payloadUsername = (payload as { username?: unknown }).username;
|
|
508
|
+
const userId = (payload as { user_id?: unknown }).user_id;
|
|
509
|
+
|
|
510
|
+
if (typeof accessToken !== 'string' || accessToken.trim() === '') {
|
|
511
|
+
throw new Error(
|
|
512
|
+
`fixture user registration did not return access_token: ${JSON.stringify(payload)}`
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const userObject =
|
|
517
|
+
typeof user === 'object' && user !== null && !Array.isArray(user)
|
|
518
|
+
? (user as JsonObject)
|
|
519
|
+
: {
|
|
520
|
+
id:
|
|
521
|
+
typeof userId === 'string' || typeof userId === 'number'
|
|
522
|
+
? userId
|
|
523
|
+
: typeof payloadUsername === 'string'
|
|
524
|
+
? payloadUsername
|
|
525
|
+
: label,
|
|
526
|
+
username:
|
|
527
|
+
typeof payloadUsername === 'string' ? payloadUsername : label,
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
accessToken,
|
|
532
|
+
refreshToken: typeof refreshToken === 'string' ? refreshToken : undefined,
|
|
533
|
+
userId:
|
|
534
|
+
typeof userId === 'string' || typeof userId === 'number'
|
|
535
|
+
? userId
|
|
536
|
+
: typeof userObject.id === 'string' || typeof userObject.id === 'number'
|
|
537
|
+
? userObject.id
|
|
538
|
+
: label,
|
|
539
|
+
username:
|
|
540
|
+
typeof payloadUsername === 'string' && payloadUsername.trim() !== ''
|
|
541
|
+
? payloadUsername
|
|
542
|
+
: label,
|
|
543
|
+
user: userObject,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function enableReplayFixtureFeatureFlag(
|
|
548
|
+
backendUrl: string,
|
|
549
|
+
accessToken: string,
|
|
550
|
+
flag: 'CLAUDE_CODE_SUPPORT_PREVIEW'
|
|
551
|
+
): Promise<void> {
|
|
552
|
+
const response = await backendFetch(
|
|
553
|
+
backendHttpUrl(backendUrl, '/api/v2/graphql'),
|
|
554
|
+
{
|
|
555
|
+
method: 'POST',
|
|
556
|
+
headers: authenticatedJsonHeaders(accessToken),
|
|
557
|
+
body: JSON.stringify({
|
|
558
|
+
query: `
|
|
559
|
+
mutation EnableReplayFixtureFeatureFlag(
|
|
560
|
+
$flag: ViewerFeatureFlagKey!
|
|
561
|
+
$enabled: Boolean!
|
|
562
|
+
) {
|
|
563
|
+
updateViewerFeatureFlag(input: {flag: $flag, enabled: $enabled}) {
|
|
564
|
+
viewer {
|
|
565
|
+
featureFlags {
|
|
566
|
+
claudeCodeSupportPreview
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
`,
|
|
572
|
+
variables: { enabled: true, flag },
|
|
573
|
+
}),
|
|
574
|
+
},
|
|
575
|
+
'fixture feature flag enable'
|
|
576
|
+
);
|
|
577
|
+
const payload = await parseJsonObjectResponse(
|
|
578
|
+
response,
|
|
579
|
+
'fixture feature flag enable'
|
|
580
|
+
);
|
|
581
|
+
const errors = (payload as { errors?: unknown }).errors;
|
|
582
|
+
|
|
583
|
+
if (errors !== undefined) {
|
|
584
|
+
throw new Error(
|
|
585
|
+
`fixture feature flag enable GraphQL errors: ${JSON.stringify(errors)}`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const data = objectField(payload, 'data');
|
|
590
|
+
const result = objectField(data, 'updateViewerFeatureFlag');
|
|
591
|
+
const viewer = objectField(result, 'viewer');
|
|
592
|
+
const featureFlags = objectField(viewer, 'featureFlags');
|
|
593
|
+
|
|
594
|
+
if (
|
|
595
|
+
(featureFlags as { readonly claudeCodeSupportPreview?: unknown })
|
|
596
|
+
.claudeCodeSupportPreview !== true
|
|
597
|
+
) {
|
|
598
|
+
throw new Error(
|
|
599
|
+
`fixture feature flag enable did not enable Claude Code support: ${JSON.stringify(payload)}`
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function createReplayWorkspace(
|
|
605
|
+
backendUrl: string,
|
|
606
|
+
accessToken: string,
|
|
607
|
+
input: { readonly name: string; readonly slug: string }
|
|
608
|
+
): Promise<void> {
|
|
609
|
+
const response = await backendFetch(
|
|
610
|
+
backendHttpUrl(backendUrl, '/api/v2/workspaces'),
|
|
611
|
+
{
|
|
612
|
+
method: 'POST',
|
|
613
|
+
headers: authenticatedJsonHeaders(accessToken),
|
|
614
|
+
body: JSON.stringify(input),
|
|
615
|
+
},
|
|
616
|
+
'fixture workspace create'
|
|
617
|
+
);
|
|
618
|
+
const payload = await parseJsonObjectResponse(
|
|
619
|
+
response,
|
|
620
|
+
'fixture workspace create'
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
if (typeof (payload as { workspace?: unknown }).workspace !== 'object') {
|
|
624
|
+
throw new Error(
|
|
625
|
+
`fixture workspace create did not return workspace: ${JSON.stringify(payload)}`
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function createReplayChannel(
|
|
631
|
+
backendUrl: string,
|
|
632
|
+
accessToken: string,
|
|
633
|
+
input: {
|
|
634
|
+
readonly workspace: string;
|
|
635
|
+
readonly name: string;
|
|
636
|
+
readonly slug: string;
|
|
637
|
+
}
|
|
638
|
+
): Promise<void> {
|
|
639
|
+
const response = await backendFetch(
|
|
640
|
+
backendHttpUrl(
|
|
641
|
+
backendUrl,
|
|
642
|
+
`/api/v2/workspaces/${encodeURIComponent(input.workspace)}/channels`
|
|
643
|
+
),
|
|
644
|
+
{
|
|
645
|
+
method: 'POST',
|
|
646
|
+
headers: authenticatedJsonHeaders(accessToken),
|
|
647
|
+
body: JSON.stringify({
|
|
648
|
+
conversation_type: 'public_channel',
|
|
649
|
+
name: input.name,
|
|
650
|
+
slug: input.slug,
|
|
651
|
+
topic: 'Agent backend replay fixture channel',
|
|
652
|
+
}),
|
|
653
|
+
},
|
|
654
|
+
'fixture channel create'
|
|
655
|
+
);
|
|
656
|
+
const payload = await parseJsonObjectResponse(
|
|
657
|
+
response,
|
|
658
|
+
'fixture channel create'
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
if (typeof (payload as { channel?: unknown }).channel !== 'object') {
|
|
662
|
+
throw new Error(
|
|
663
|
+
`fixture channel create did not return channel: ${JSON.stringify(payload)}`
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function authenticatedJsonHeaders(accessToken: string): Record<string, string> {
|
|
669
|
+
return {
|
|
670
|
+
accept: 'application/json',
|
|
671
|
+
authorization: `Bearer ${accessToken}`,
|
|
672
|
+
'content-type': 'application/json',
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function backendFetch(
|
|
677
|
+
url: string,
|
|
678
|
+
init: RequestInit,
|
|
679
|
+
label: string
|
|
680
|
+
): Promise<Response> {
|
|
681
|
+
try {
|
|
682
|
+
return await fetch(url, init);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
685
|
+
throw new Error(`${label} request failed for ${url}: ${message}`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function parseJsonObjectResponse(
|
|
690
|
+
response: Response,
|
|
691
|
+
label: string
|
|
692
|
+
): Promise<object> {
|
|
693
|
+
const payload: unknown = await response.json().catch(() => undefined);
|
|
694
|
+
|
|
695
|
+
if (
|
|
696
|
+
!response.ok ||
|
|
697
|
+
typeof payload !== 'object' ||
|
|
698
|
+
payload === null ||
|
|
699
|
+
Array.isArray(payload) ||
|
|
700
|
+
(payload as { ok?: unknown }).ok === false
|
|
701
|
+
) {
|
|
702
|
+
throw new Error(
|
|
703
|
+
`${label} failed: ${response.status}:${JSON.stringify(payload)}`
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return payload;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function slugifyReplaySetup(value: string): string {
|
|
711
|
+
const slug = value
|
|
712
|
+
.toLowerCase()
|
|
713
|
+
.replace(/[^a-z0-9]+/gu, '-')
|
|
714
|
+
.replace(/^-+|-+$/gu, '')
|
|
715
|
+
.slice(0, 58);
|
|
716
|
+
|
|
717
|
+
if (slug.trim() === '') {
|
|
718
|
+
throw new Error('fixture setup label produced an empty slug');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return slug;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function runBackendReplay(
|
|
725
|
+
options: BackendReplayOptions,
|
|
726
|
+
provider: string
|
|
727
|
+
): Promise<JsonObject> {
|
|
728
|
+
if (options.backendUrl === undefined) {
|
|
729
|
+
throw new Error('backend replay requires --backend-url <url>');
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (options.workspace === undefined || options.workspace.trim() === '') {
|
|
733
|
+
throw new Error('backend replay requires --workspace <slug>');
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (options.channel === undefined || options.channel.trim() === '') {
|
|
737
|
+
throw new Error('backend replay requires --channel <slug>');
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (
|
|
741
|
+
options.sessionToken === undefined ||
|
|
742
|
+
options.sessionToken.trim() === ''
|
|
743
|
+
) {
|
|
744
|
+
throw new Error(
|
|
745
|
+
'backend replay requires --session-token <browser-session-token> or LINZUMI_AGENT_BACKEND_REPLAY_SESSION_TOKEN so it can call the normal GraphQL start mutation'
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const runnerToken = await resolveBackendReplayToken(options);
|
|
750
|
+
const runner = await runLocalCodexRunner(
|
|
751
|
+
backendReplayRunnerOptions(options, provider, runnerToken)
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
try {
|
|
755
|
+
const start = await startLocalCodexRunnerInstance(options, provider);
|
|
756
|
+
const oracles = await runBackendReplayOracles(options, start, provider);
|
|
757
|
+
|
|
758
|
+
return {
|
|
759
|
+
runnerId: options.runnerId,
|
|
760
|
+
instanceId: runner.instanceId,
|
|
761
|
+
codexUrl: runner.codexUrl,
|
|
762
|
+
frontendTarget: frontendReplayTarget(options, start.threadId),
|
|
763
|
+
start,
|
|
764
|
+
oracles,
|
|
765
|
+
};
|
|
766
|
+
} finally {
|
|
767
|
+
await runner.close();
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
type BackendReplayStartSuccess = {
|
|
772
|
+
readonly typename: 'StartLocalCodexRunnerInstanceSuccess';
|
|
773
|
+
readonly runnerKey: string;
|
|
774
|
+
readonly threadId: string;
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
type BackendReplayOracleCheck = {
|
|
778
|
+
readonly id: string;
|
|
779
|
+
readonly status: 'passed' | 'failed';
|
|
780
|
+
readonly details: JsonObject;
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
async function runBackendReplayOracles(
|
|
784
|
+
options: BackendReplayOptions,
|
|
785
|
+
start: BackendReplayStartSuccess,
|
|
786
|
+
provider: string
|
|
787
|
+
): Promise<JsonObject> {
|
|
788
|
+
const startedAtMs = Date.now();
|
|
789
|
+
const deadlineMs = startedAtMs + backendReplayOracleTimeoutMs;
|
|
790
|
+
let lastResult: JsonObject | undefined;
|
|
791
|
+
let attempts = 0;
|
|
792
|
+
|
|
793
|
+
while (Date.now() <= deadlineMs) {
|
|
794
|
+
attempts += 1;
|
|
795
|
+
const result = await evaluateBackendReplayOracles(options, start, provider);
|
|
796
|
+
lastResult = result;
|
|
797
|
+
|
|
798
|
+
if (result.status === 'passed') {
|
|
799
|
+
return {
|
|
800
|
+
...result,
|
|
801
|
+
attempts,
|
|
802
|
+
elapsedMs: Date.now() - startedAtMs,
|
|
803
|
+
timeoutMs: backendReplayOracleTimeoutMs,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
await waitForBackendReplayOraclePoll();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (lastResult === undefined) {
|
|
811
|
+
return {
|
|
812
|
+
status: 'failed',
|
|
813
|
+
checks: [],
|
|
814
|
+
messageStates: [],
|
|
815
|
+
threadOutput: [],
|
|
816
|
+
attempts,
|
|
817
|
+
elapsedMs: Date.now() - startedAtMs,
|
|
818
|
+
timeoutMs: backendReplayOracleTimeoutMs,
|
|
819
|
+
timeoutReason: 'backend replay oracle did not run before timeout',
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return {
|
|
824
|
+
...lastResult,
|
|
825
|
+
attempts,
|
|
826
|
+
elapsedMs: Date.now() - startedAtMs,
|
|
827
|
+
timeoutMs: backendReplayOracleTimeoutMs,
|
|
828
|
+
timeoutReason:
|
|
829
|
+
'backend replay oracle conditions did not pass before timeout',
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function waitForBackendReplayOraclePoll(): Promise<void> {
|
|
834
|
+
return new Promise((resolve) => {
|
|
835
|
+
const timeout = setTimeout(resolve, backendReplayOraclePollMs);
|
|
836
|
+
timeout.unref?.();
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function evaluateBackendReplayOracles(
|
|
841
|
+
options: BackendReplayOptions,
|
|
842
|
+
start: BackendReplayStartSuccess,
|
|
843
|
+
provider: string
|
|
844
|
+
): Promise<JsonObject> {
|
|
845
|
+
const statesResult = await queryBackendReplayMessageStates(
|
|
846
|
+
options,
|
|
847
|
+
start.threadId
|
|
848
|
+
);
|
|
849
|
+
const checks: BackendReplayOracleCheck[] = [];
|
|
850
|
+
|
|
851
|
+
checks.push({
|
|
852
|
+
id: 'message-states-present',
|
|
853
|
+
status: statesResult.states.length > 0 ? 'passed' : 'failed',
|
|
854
|
+
details: { count: statesResult.states.length },
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
const runnerStates = statesResult.states.filter(
|
|
858
|
+
(state) => state.runnerKey === options.runnerId
|
|
859
|
+
);
|
|
860
|
+
checks.push({
|
|
861
|
+
id: 'message-states-runner-key',
|
|
862
|
+
status: runnerStates.length > 0 ? 'passed' : 'failed',
|
|
863
|
+
details: { runnerId: options.runnerId, count: runnerStates.length },
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
const terminalStates = runnerStates.filter(
|
|
867
|
+
(state) => state.status === 'processed' || state.status === 'failed'
|
|
868
|
+
);
|
|
869
|
+
checks.push({
|
|
870
|
+
id: 'message-state-terminal',
|
|
871
|
+
status: terminalStates.length > 0 ? 'passed' : 'failed',
|
|
872
|
+
details: {
|
|
873
|
+
terminalStatuses: terminalStates.map((state) => state.status),
|
|
874
|
+
allStatuses: runnerStates.map((state) => state.status),
|
|
875
|
+
},
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
const processedState = terminalStates.find(
|
|
879
|
+
(state) => state.status === 'processed'
|
|
880
|
+
);
|
|
881
|
+
checks.push({
|
|
882
|
+
id: 'message-state-processed',
|
|
883
|
+
status: processedState === undefined ? 'failed' : 'passed',
|
|
884
|
+
details: {
|
|
885
|
+
processedSeq: processedState?.seq,
|
|
886
|
+
failedReasons: terminalStates
|
|
887
|
+
.filter((state) => state.status === 'failed')
|
|
888
|
+
.map((state) => state.reason ?? 'unknown'),
|
|
889
|
+
},
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
const output =
|
|
893
|
+
processedState === undefined
|
|
894
|
+
? { sourceSeq: undefined, items: [] as BackendReplayThreadOutputItem[] }
|
|
895
|
+
: await queryBackendReplayThreadOutput(
|
|
896
|
+
options,
|
|
897
|
+
start.threadId,
|
|
898
|
+
processedState.seq
|
|
899
|
+
);
|
|
900
|
+
checks.push({
|
|
901
|
+
id: 'thread-output-present',
|
|
902
|
+
status: output.items.length > 0 ? 'passed' : 'failed',
|
|
903
|
+
details: { sourceSeq: output.sourceSeq, count: output.items.length },
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
const supportedOutputTypenames = new Set([
|
|
907
|
+
'ThreadFeedCodexAssistantMessage',
|
|
908
|
+
'ThreadFeedCodexCommandExecutionMessage',
|
|
909
|
+
'ThreadFeedCodexFileChangeMessage',
|
|
910
|
+
'ThreadFeedCodexReasoningMessage',
|
|
911
|
+
'ThreadFeedAgentStatusEvent',
|
|
912
|
+
'ThreadFeedAgentTaskUpdateEvent',
|
|
913
|
+
]);
|
|
914
|
+
const unsupportedOutputItems = output.items.filter(
|
|
915
|
+
(item) => !supportedOutputTypenames.has(item.typename)
|
|
916
|
+
);
|
|
917
|
+
checks.push({
|
|
918
|
+
id: 'thread-output-supported-types',
|
|
919
|
+
status:
|
|
920
|
+
output.items.length > 0 && unsupportedOutputItems.length === 0
|
|
921
|
+
? 'passed'
|
|
922
|
+
: 'failed',
|
|
923
|
+
details: {
|
|
924
|
+
typenames: output.items.map((item) => item.typename),
|
|
925
|
+
unsupportedTypenames: unsupportedOutputItems.map((item) => item.typename),
|
|
926
|
+
},
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
const assistantItems = output.items.filter(
|
|
930
|
+
(item) => item.typename === 'ThreadFeedCodexAssistantMessage'
|
|
931
|
+
);
|
|
932
|
+
checks.push({
|
|
933
|
+
id: 'thread-output-assistant-present',
|
|
934
|
+
status: assistantItems.length > 0 ? 'passed' : 'failed',
|
|
935
|
+
details: { count: assistantItems.length },
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
const providerItems = output.items.filter((item) => {
|
|
939
|
+
switch (provider) {
|
|
940
|
+
case 'claude-code':
|
|
941
|
+
return item.agentProvider === 'claude-code';
|
|
942
|
+
case 'codex':
|
|
943
|
+
return (
|
|
944
|
+
item.agentProvider === 'codex' || item.agentProvider === undefined
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
checks.push({
|
|
949
|
+
id: 'thread-output-provider',
|
|
950
|
+
status:
|
|
951
|
+
output.items.length > 0 && providerItems.length === output.items.length
|
|
952
|
+
? 'passed'
|
|
953
|
+
: 'failed',
|
|
954
|
+
details: {
|
|
955
|
+
expectedProvider: provider,
|
|
956
|
+
acceptedProviders:
|
|
957
|
+
provider === 'codex' ? ['codex', null] : ['claude-code'],
|
|
958
|
+
providers: output.items.map((item) => item.agentProvider ?? null),
|
|
959
|
+
},
|
|
960
|
+
});
|
|
961
|
+
checks.push(
|
|
962
|
+
...featureBackendReplayOracleChecks({
|
|
963
|
+
featureId: options.oracleFeatureId ?? options.requiredFeatureId,
|
|
964
|
+
provider,
|
|
965
|
+
outputItems: output.items,
|
|
966
|
+
})
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
const failedChecks = checks.filter((check) => check.status === 'failed');
|
|
970
|
+
|
|
971
|
+
return {
|
|
972
|
+
status: failedChecks.length === 0 ? 'passed' : 'failed',
|
|
973
|
+
checks,
|
|
974
|
+
messageStates: statesResult.states,
|
|
975
|
+
threadOutput: output.items,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
type BackendReplayMessageState = {
|
|
980
|
+
readonly seq: number;
|
|
981
|
+
readonly status: string;
|
|
982
|
+
readonly reason: string | undefined;
|
|
983
|
+
readonly runnerKey: string | undefined;
|
|
984
|
+
readonly updatedAt: string | undefined;
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
type BackendReplayMessageStatesResult = {
|
|
988
|
+
readonly states: readonly BackendReplayMessageState[];
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
async function queryBackendReplayMessageStates(
|
|
992
|
+
options: BackendReplayOptions,
|
|
993
|
+
threadId: string
|
|
994
|
+
): Promise<BackendReplayMessageStatesResult> {
|
|
995
|
+
const data = await postGraphql(options, {
|
|
996
|
+
operationName: 'AgentBackendReplayMessageStates',
|
|
997
|
+
query: backendReplayMessageStatesQuery,
|
|
998
|
+
variables: backendReplayGraphqlVariables(options, threadId),
|
|
999
|
+
});
|
|
1000
|
+
const connection = objectField(data, 'threadFeedConnection');
|
|
1001
|
+
const rawStates = arrayField(connection, 'localCodexMessageStates');
|
|
1002
|
+
|
|
1003
|
+
return {
|
|
1004
|
+
states: rawStates.map(parseBackendReplayMessageState),
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
type BackendReplayThreadOutputItem = {
|
|
1009
|
+
readonly typename: string;
|
|
1010
|
+
readonly seq: number | undefined;
|
|
1011
|
+
readonly content: string | undefined;
|
|
1012
|
+
readonly sourceMessageSeq: number | undefined;
|
|
1013
|
+
readonly streamKey: string | undefined;
|
|
1014
|
+
readonly streamState: string | undefined;
|
|
1015
|
+
readonly agentProvider: string | undefined;
|
|
1016
|
+
readonly kind: string | undefined;
|
|
1017
|
+
readonly title: string | undefined;
|
|
1018
|
+
readonly body: string | undefined;
|
|
1019
|
+
readonly severity: string | undefined;
|
|
1020
|
+
readonly state: string | undefined;
|
|
1021
|
+
readonly progressLabel: string | undefined;
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
async function queryBackendReplayThreadOutput(
|
|
1025
|
+
options: BackendReplayOptions,
|
|
1026
|
+
threadId: string,
|
|
1027
|
+
sourceSeq: number
|
|
1028
|
+
): Promise<{
|
|
1029
|
+
readonly sourceSeq: number;
|
|
1030
|
+
readonly items: readonly BackendReplayThreadOutputItem[];
|
|
1031
|
+
}> {
|
|
1032
|
+
const data = await postGraphql(options, {
|
|
1033
|
+
operationName: 'AgentBackendReplayThreadOutput',
|
|
1034
|
+
query: backendReplayThreadOutputQuery,
|
|
1035
|
+
variables: {
|
|
1036
|
+
...backendReplayGraphqlVariables(options, threadId),
|
|
1037
|
+
sourceSeq,
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
const rawItems = arrayField(data, 'localCodexThreadOutput');
|
|
1041
|
+
|
|
1042
|
+
return {
|
|
1043
|
+
sourceSeq,
|
|
1044
|
+
items: rawItems.map(parseBackendReplayThreadOutputItem),
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function backendReplayGraphqlVariables(
|
|
1049
|
+
options: BackendReplayOptions,
|
|
1050
|
+
threadId: string
|
|
1051
|
+
): JsonObject {
|
|
1052
|
+
if (options.workspace === undefined || options.channel === undefined) {
|
|
1053
|
+
throw new Error('backend oracle requires workspace and channel');
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return {
|
|
1057
|
+
workspaceSlug: options.workspace,
|
|
1058
|
+
channelSlug: options.channel,
|
|
1059
|
+
threadId,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function parseBackendReplayMessageState(
|
|
1064
|
+
value: unknown
|
|
1065
|
+
): BackendReplayMessageState {
|
|
1066
|
+
const state = objectFromUnknown(value, 'localCodexMessageState');
|
|
1067
|
+
const seq = numberField(state, 'seq');
|
|
1068
|
+
const status = stringField(state, 'status');
|
|
1069
|
+
|
|
1070
|
+
return {
|
|
1071
|
+
seq,
|
|
1072
|
+
status,
|
|
1073
|
+
reason: optionalStringField(state, 'reason'),
|
|
1074
|
+
runnerKey: optionalStringField(state, 'runnerKey'),
|
|
1075
|
+
updatedAt: optionalStringField(state, 'updatedAt'),
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function parseBackendReplayThreadOutputItem(
|
|
1080
|
+
value: unknown
|
|
1081
|
+
): BackendReplayThreadOutputItem {
|
|
1082
|
+
const item = objectFromUnknown(value, 'localCodexThreadOutput item');
|
|
1083
|
+
|
|
1084
|
+
return {
|
|
1085
|
+
typename: stringField(item, '__typename'),
|
|
1086
|
+
seq: optionalNumberField(item, 'seq'),
|
|
1087
|
+
content: optionalStringField(item, 'content'),
|
|
1088
|
+
sourceMessageSeq: optionalNumberField(item, 'sourceMessageSeq'),
|
|
1089
|
+
streamKey: optionalStringField(item, 'streamKey'),
|
|
1090
|
+
streamState: optionalStringField(item, 'streamState'),
|
|
1091
|
+
agentProvider: optionalStringField(item, 'agentProvider'),
|
|
1092
|
+
kind: optionalStringField(item, 'kind'),
|
|
1093
|
+
title: optionalStringField(item, 'title'),
|
|
1094
|
+
body: optionalStringField(item, 'body'),
|
|
1095
|
+
severity: optionalStringField(item, 'severity'),
|
|
1096
|
+
state: optionalStringField(item, 'state'),
|
|
1097
|
+
progressLabel: optionalStringField(item, 'progressLabel'),
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async function postGraphql(
|
|
1102
|
+
options: BackendReplayOptions,
|
|
1103
|
+
body: JsonObject
|
|
1104
|
+
): Promise<object> {
|
|
1105
|
+
if (options.backendUrl === undefined) {
|
|
1106
|
+
throw new Error('GraphQL request requires --backend-url <url>');
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (
|
|
1110
|
+
options.sessionToken === undefined ||
|
|
1111
|
+
options.sessionToken.trim() === ''
|
|
1112
|
+
) {
|
|
1113
|
+
throw new Error('GraphQL request requires a non-empty session token');
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const response = await backendFetch(
|
|
1117
|
+
backendHttpUrl(options.backendUrl, '/api/v2/graphql'),
|
|
1118
|
+
{
|
|
1119
|
+
method: 'POST',
|
|
1120
|
+
headers: {
|
|
1121
|
+
accept: 'application/json',
|
|
1122
|
+
authorization: `Bearer ${options.sessionToken}`,
|
|
1123
|
+
'content-type': 'application/json',
|
|
1124
|
+
},
|
|
1125
|
+
body: JSON.stringify(body),
|
|
1126
|
+
},
|
|
1127
|
+
'GraphQL request'
|
|
1128
|
+
);
|
|
1129
|
+
const payload = await parseJsonObjectResponse(response, 'GraphQL request');
|
|
1130
|
+
const errors = (payload as { errors?: unknown }).errors;
|
|
1131
|
+
|
|
1132
|
+
if (errors !== undefined) {
|
|
1133
|
+
throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
return objectField(payload, 'data');
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function objectField(value: object, field: string): object {
|
|
1140
|
+
return objectFromUnknown((value as Record<string, unknown>)[field], field);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function arrayField(value: object, field: string): readonly unknown[] {
|
|
1144
|
+
const raw = (value as Record<string, unknown>)[field];
|
|
1145
|
+
|
|
1146
|
+
if (!Array.isArray(raw)) {
|
|
1147
|
+
throw new Error(`${field} must be an array`);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
return raw;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function objectFromUnknown(value: unknown, label: string): object {
|
|
1154
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
1155
|
+
throw new Error(`${label} must be an object`);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
return value;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function stringField(value: object, field: string): string {
|
|
1162
|
+
const raw = (value as Record<string, unknown>)[field];
|
|
1163
|
+
|
|
1164
|
+
if (typeof raw !== 'string') {
|
|
1165
|
+
throw new Error(`${field} must be a string`);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
return raw;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function optionalStringField(value: object, field: string): string | undefined {
|
|
1172
|
+
const raw = (value as Record<string, unknown>)[field];
|
|
1173
|
+
|
|
1174
|
+
if (raw === null || raw === undefined) {
|
|
1175
|
+
return undefined;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (typeof raw !== 'string') {
|
|
1179
|
+
throw new Error(`${field} must be a string when present`);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
return raw;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function numberField(value: object, field: string): number {
|
|
1186
|
+
const raw = (value as Record<string, unknown>)[field];
|
|
1187
|
+
|
|
1188
|
+
if (!Number.isInteger(raw)) {
|
|
1189
|
+
throw new Error(`${field} must be an integer`);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
return raw;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function optionalNumberField(value: object, field: string): number | undefined {
|
|
1196
|
+
const raw = (value as Record<string, unknown>)[field];
|
|
1197
|
+
|
|
1198
|
+
if (raw === null || raw === undefined) {
|
|
1199
|
+
return undefined;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
if (!Number.isInteger(raw)) {
|
|
1203
|
+
throw new Error(`${field} must be an integer when present`);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
return raw;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const backendReplayMessageStatesQuery = `
|
|
1210
|
+
query AgentBackendReplayMessageStates(
|
|
1211
|
+
$workspaceSlug: String!
|
|
1212
|
+
$channelSlug: String!
|
|
1213
|
+
$threadId: ID!
|
|
1214
|
+
) {
|
|
1215
|
+
threadFeedConnection(
|
|
1216
|
+
workspaceSlug: $workspaceSlug
|
|
1217
|
+
channelSlug: $channelSlug
|
|
1218
|
+
threadId: $threadId
|
|
1219
|
+
last: 20
|
|
1220
|
+
) {
|
|
1221
|
+
localCodexMessageStates {
|
|
1222
|
+
seq
|
|
1223
|
+
status
|
|
1224
|
+
reason
|
|
1225
|
+
runnerKey
|
|
1226
|
+
updatedAt
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
`;
|
|
1231
|
+
|
|
1232
|
+
const backendReplayThreadOutputQuery = `
|
|
1233
|
+
query AgentBackendReplayThreadOutput(
|
|
1234
|
+
$workspaceSlug: String!
|
|
1235
|
+
$channelSlug: String!
|
|
1236
|
+
$threadId: ID!
|
|
1237
|
+
$sourceSeq: Int!
|
|
1238
|
+
) {
|
|
1239
|
+
localCodexThreadOutput(
|
|
1240
|
+
workspaceSlug: $workspaceSlug
|
|
1241
|
+
channelSlug: $channelSlug
|
|
1242
|
+
threadId: $threadId
|
|
1243
|
+
sourceSeq: $sourceSeq
|
|
1244
|
+
) {
|
|
1245
|
+
__typename
|
|
1246
|
+
... on ThreadFeedEntry {
|
|
1247
|
+
seq
|
|
1248
|
+
}
|
|
1249
|
+
... on ThreadFeedCodexReasoningMessage {
|
|
1250
|
+
sourceMessageSeq
|
|
1251
|
+
streamKey
|
|
1252
|
+
streamState
|
|
1253
|
+
agentProvider
|
|
1254
|
+
}
|
|
1255
|
+
... on ThreadFeedCodexAssistantMessage {
|
|
1256
|
+
content
|
|
1257
|
+
sourceMessageSeq
|
|
1258
|
+
streamKey
|
|
1259
|
+
streamState
|
|
1260
|
+
agentProvider
|
|
1261
|
+
}
|
|
1262
|
+
... on ThreadFeedCodexCommandExecutionMessage {
|
|
1263
|
+
sourceMessageSeq
|
|
1264
|
+
streamKey
|
|
1265
|
+
streamState
|
|
1266
|
+
agentProvider
|
|
1267
|
+
}
|
|
1268
|
+
... on ThreadFeedCodexFileChangeMessage {
|
|
1269
|
+
sourceMessageSeq
|
|
1270
|
+
streamKey
|
|
1271
|
+
streamState
|
|
1272
|
+
agentProvider
|
|
1273
|
+
}
|
|
1274
|
+
... on ThreadFeedAgentStatusEvent {
|
|
1275
|
+
sourceMessageSeq
|
|
1276
|
+
agentProvider
|
|
1277
|
+
kind
|
|
1278
|
+
title
|
|
1279
|
+
body
|
|
1280
|
+
severity
|
|
1281
|
+
}
|
|
1282
|
+
... on ThreadFeedAgentTaskUpdateEvent {
|
|
1283
|
+
sourceMessageSeq
|
|
1284
|
+
agentProvider
|
|
1285
|
+
kind
|
|
1286
|
+
title
|
|
1287
|
+
body
|
|
1288
|
+
state
|
|
1289
|
+
progressLabel
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
`;
|
|
1294
|
+
|
|
1295
|
+
function backendReplayRunnerOptions(
|
|
1296
|
+
options: BackendReplayOptions,
|
|
1297
|
+
provider: string,
|
|
1298
|
+
token: string
|
|
1299
|
+
): RunnerOptions {
|
|
1300
|
+
return {
|
|
1301
|
+
kandanUrl: options.backendUrl ?? '',
|
|
1302
|
+
token,
|
|
1303
|
+
runnerId: options.runnerId,
|
|
1304
|
+
clientId: 'agent-backend-replay',
|
|
1305
|
+
workspaceSlug: options.workspace,
|
|
1306
|
+
cwd: options.cwd,
|
|
1307
|
+
codexBin: process.env.CODEX_BIN ?? 'codex',
|
|
1308
|
+
codexUrl: undefined,
|
|
1309
|
+
launchTui: false,
|
|
1310
|
+
allowedCwds: [options.cwd],
|
|
1311
|
+
channelSession: undefined,
|
|
1312
|
+
agentReplay: {
|
|
1313
|
+
fixturePath: options.fixturePath,
|
|
1314
|
+
provider: options.provider,
|
|
1315
|
+
clockMode: parseAgentReplayClockMode(options.speed),
|
|
1316
|
+
},
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
async function startLocalCodexRunnerInstance(
|
|
1321
|
+
options: BackendReplayOptions,
|
|
1322
|
+
provider: string
|
|
1323
|
+
): Promise<BackendReplayStartSuccess> {
|
|
1324
|
+
if (options.backendUrl === undefined) {
|
|
1325
|
+
throw new Error('start mutation requires --backend-url <url>');
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (options.workspace === undefined || options.channel === undefined) {
|
|
1329
|
+
throw new Error('start mutation requires --workspace and --channel');
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
if (
|
|
1333
|
+
options.sessionToken === undefined ||
|
|
1334
|
+
options.sessionToken.trim() === ''
|
|
1335
|
+
) {
|
|
1336
|
+
throw new Error('start mutation requires a non-empty session token');
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const response = await backendFetch(
|
|
1340
|
+
backendHttpUrl(options.backendUrl, '/api/v2/graphql'),
|
|
1341
|
+
{
|
|
1342
|
+
method: 'POST',
|
|
1343
|
+
headers: {
|
|
1344
|
+
accept: 'application/json',
|
|
1345
|
+
authorization: `Bearer ${options.sessionToken}`,
|
|
1346
|
+
'content-type': 'application/json',
|
|
1347
|
+
},
|
|
1348
|
+
body: JSON.stringify({
|
|
1349
|
+
operationName: 'AgentBackendReplayStartLocalCodexRunnerInstance',
|
|
1350
|
+
query: startLocalCodexRunnerInstanceMutation,
|
|
1351
|
+
variables: {
|
|
1352
|
+
workspaceSlug: options.workspace,
|
|
1353
|
+
channelSlug: options.channel,
|
|
1354
|
+
runnerId: options.runnerId,
|
|
1355
|
+
cwd: options.cwd,
|
|
1356
|
+
workDescription: options.workDescription,
|
|
1357
|
+
agentProvider: provider,
|
|
1358
|
+
allowPortForwardingByDefault: true,
|
|
1359
|
+
},
|
|
1360
|
+
}),
|
|
1361
|
+
},
|
|
1362
|
+
'startLocalCodexRunnerInstance GraphQL request'
|
|
1363
|
+
);
|
|
1364
|
+
const payload: unknown = await response.json();
|
|
1365
|
+
|
|
1366
|
+
if (!response.ok) {
|
|
1367
|
+
throw new Error(
|
|
1368
|
+
`startLocalCodexRunnerInstance HTTP failed: ${response.status}:${JSON.stringify(payload)}`
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
if (
|
|
1373
|
+
typeof payload !== 'object' ||
|
|
1374
|
+
payload === null ||
|
|
1375
|
+
Array.isArray(payload)
|
|
1376
|
+
) {
|
|
1377
|
+
throw new Error(
|
|
1378
|
+
'startLocalCodexRunnerInstance returned a non-object payload'
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const errors = (payload as { errors?: unknown }).errors;
|
|
1383
|
+
if (errors !== undefined) {
|
|
1384
|
+
throw new Error(
|
|
1385
|
+
`startLocalCodexRunnerInstance GraphQL errors: ${JSON.stringify(errors)}`
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const result = startMutationResult(payload);
|
|
1390
|
+
|
|
1391
|
+
switch (result.__typename) {
|
|
1392
|
+
case 'StartLocalCodexRunnerInstanceSuccess':
|
|
1393
|
+
return {
|
|
1394
|
+
typename: result.__typename,
|
|
1395
|
+
runnerKey: result.runnerKey,
|
|
1396
|
+
threadId: result.threadId,
|
|
1397
|
+
};
|
|
1398
|
+
case 'StartLocalCodexRunnerInstanceUnavailable':
|
|
1399
|
+
throw new Error(
|
|
1400
|
+
`startLocalCodexRunnerInstance unavailable: ${result.reason}`
|
|
1401
|
+
);
|
|
1402
|
+
default:
|
|
1403
|
+
throw new Error(
|
|
1404
|
+
`startLocalCodexRunnerInstance returned unexpected result: ${JSON.stringify(result)}`
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function startMutationResult(payload: object):
|
|
1410
|
+
| {
|
|
1411
|
+
readonly __typename: 'StartLocalCodexRunnerInstanceSuccess';
|
|
1412
|
+
readonly runnerKey: string;
|
|
1413
|
+
readonly threadId: string;
|
|
1414
|
+
}
|
|
1415
|
+
| {
|
|
1416
|
+
readonly __typename: 'StartLocalCodexRunnerInstanceUnavailable';
|
|
1417
|
+
readonly reason: string;
|
|
1418
|
+
}
|
|
1419
|
+
| { readonly __typename: string } {
|
|
1420
|
+
const data = (payload as { data?: unknown }).data;
|
|
1421
|
+
|
|
1422
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
1423
|
+
throw new Error('startLocalCodexRunnerInstance missing data');
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const result = (data as { startLocalCodexRunnerInstance?: unknown })
|
|
1427
|
+
.startLocalCodexRunnerInstance;
|
|
1428
|
+
|
|
1429
|
+
if (typeof result !== 'object' || result === null || Array.isArray(result)) {
|
|
1430
|
+
throw new Error('startLocalCodexRunnerInstance missing result');
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const typename = (result as { __typename?: unknown }).__typename;
|
|
1434
|
+
|
|
1435
|
+
if (typename === 'StartLocalCodexRunnerInstanceSuccess') {
|
|
1436
|
+
const runnerKey = (result as { runnerKey?: unknown }).runnerKey;
|
|
1437
|
+
const threadId = (result as { threadId?: unknown }).threadId;
|
|
1438
|
+
|
|
1439
|
+
if (typeof runnerKey !== 'string' || typeof threadId !== 'string') {
|
|
1440
|
+
throw new Error(
|
|
1441
|
+
`startLocalCodexRunnerInstance success had invalid fields: ${JSON.stringify(result)}`
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
return { __typename: typename, runnerKey, threadId };
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
if (typename === 'StartLocalCodexRunnerInstanceUnavailable') {
|
|
1449
|
+
const reason = (result as { reason?: unknown }).reason;
|
|
1450
|
+
|
|
1451
|
+
if (typeof reason !== 'string') {
|
|
1452
|
+
throw new Error(
|
|
1453
|
+
`startLocalCodexRunnerInstance unavailable had invalid fields: ${JSON.stringify(result)}`
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
return { __typename: typename, reason };
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
if (typeof typename === 'string') {
|
|
1461
|
+
return { __typename: typename };
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
throw new Error(
|
|
1465
|
+
`startLocalCodexRunnerInstance result missing __typename: ${JSON.stringify(result)}`
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const startLocalCodexRunnerInstanceMutation = `
|
|
1470
|
+
mutation AgentBackendReplayStartLocalCodexRunnerInstance(
|
|
1471
|
+
$workspaceSlug: String!
|
|
1472
|
+
$channelSlug: String!
|
|
1473
|
+
$runnerId: String!
|
|
1474
|
+
$cwd: String!
|
|
1475
|
+
$workDescription: String!
|
|
1476
|
+
$agentProvider: String
|
|
1477
|
+
$allowPortForwardingByDefault: Boolean
|
|
1478
|
+
) {
|
|
1479
|
+
startLocalCodexRunnerInstance(
|
|
1480
|
+
input: {
|
|
1481
|
+
workspaceSlug: $workspaceSlug
|
|
1482
|
+
channelSlug: $channelSlug
|
|
1483
|
+
runnerKey: $runnerId
|
|
1484
|
+
cwd: $cwd
|
|
1485
|
+
workDescription: $workDescription
|
|
1486
|
+
agentProvider: $agentProvider
|
|
1487
|
+
allowPortForwardingByDefault: $allowPortForwardingByDefault
|
|
1488
|
+
}
|
|
1489
|
+
) {
|
|
1490
|
+
__typename
|
|
1491
|
+
... on StartLocalCodexRunnerInstanceSuccess {
|
|
1492
|
+
runnerKey
|
|
1493
|
+
threadId
|
|
1494
|
+
}
|
|
1495
|
+
... on StartLocalCodexRunnerInstanceUnavailable {
|
|
1496
|
+
reason
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
`;
|
|
1501
|
+
|
|
1502
|
+
async function probeBackend(
|
|
1503
|
+
options: BackendReplayOptions
|
|
1504
|
+
): Promise<JsonObject> {
|
|
1505
|
+
if (options.backendUrl === undefined) {
|
|
1506
|
+
throw new Error('--probe-backend requires --backend-url <url>');
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
const token = await resolveBackendReplayToken(options);
|
|
1510
|
+
|
|
1511
|
+
const client = await connectPhoenixClient(options.backendUrl, token);
|
|
1512
|
+
|
|
1513
|
+
try {
|
|
1514
|
+
const topic = `local_runner:${options.runnerId}`;
|
|
1515
|
+
const reply = await client.join(topic, backendProbeJoinPayload(options), {
|
|
1516
|
+
registrationId: 'agent-backend-replay-probe',
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
return {
|
|
1520
|
+
topic,
|
|
1521
|
+
ok: reply.ok === true,
|
|
1522
|
+
protocol: typeof reply.protocol === 'string' ? reply.protocol : undefined,
|
|
1523
|
+
runnerId:
|
|
1524
|
+
typeof reply.runner_id === 'string' ? reply.runner_id : undefined,
|
|
1525
|
+
serverCapabilities:
|
|
1526
|
+
typeof reply.server_capabilities === 'object' &&
|
|
1527
|
+
reply.server_capabilities !== null &&
|
|
1528
|
+
!Array.isArray(reply.server_capabilities)
|
|
1529
|
+
? reply.server_capabilities
|
|
1530
|
+
: undefined,
|
|
1531
|
+
};
|
|
1532
|
+
} finally {
|
|
1533
|
+
client.close();
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
async function resolveBackendReplayToken(
|
|
1538
|
+
options: BackendReplayOptions
|
|
1539
|
+
): Promise<string> {
|
|
1540
|
+
if (options.token !== undefined && options.token.trim() !== '') {
|
|
1541
|
+
return options.token;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
if (
|
|
1545
|
+
options.sessionToken !== undefined &&
|
|
1546
|
+
options.sessionToken.trim() !== ''
|
|
1547
|
+
) {
|
|
1548
|
+
return exchangeSessionTokenForLocalRunnerToken(options);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
throw new Error(
|
|
1552
|
+
'--probe-backend requires --token <local-runner-token>, LINZUMI_AGENT_BACKEND_REPLAY_TOKEN, --session-token <session-token>, or LINZUMI_AGENT_BACKEND_REPLAY_SESSION_TOKEN'
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
async function exchangeSessionTokenForLocalRunnerToken(
|
|
1557
|
+
options: BackendReplayOptions
|
|
1558
|
+
): Promise<string> {
|
|
1559
|
+
if (options.backendUrl === undefined) {
|
|
1560
|
+
throw new Error('--session-token exchange requires --backend-url <url>');
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
if (options.workspace === undefined || options.workspace.trim() === '') {
|
|
1564
|
+
throw new Error('--session-token exchange requires --workspace <slug>');
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
if (
|
|
1568
|
+
options.sessionToken === undefined ||
|
|
1569
|
+
options.sessionToken.trim() === ''
|
|
1570
|
+
) {
|
|
1571
|
+
throw new Error(
|
|
1572
|
+
'--session-token exchange requires a non-empty session token'
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
const endpoint = backendHttpUrl(
|
|
1577
|
+
options.backendUrl,
|
|
1578
|
+
'/api/v2/local-codex-runner/oauth/session-exchange'
|
|
1579
|
+
);
|
|
1580
|
+
const body =
|
|
1581
|
+
options.channel === undefined || options.channel.trim() === ''
|
|
1582
|
+
? { workspace: options.workspace }
|
|
1583
|
+
: { workspace: options.workspace, channel: options.channel };
|
|
1584
|
+
|
|
1585
|
+
const response = await backendFetch(
|
|
1586
|
+
endpoint,
|
|
1587
|
+
{
|
|
1588
|
+
method: 'POST',
|
|
1589
|
+
headers: {
|
|
1590
|
+
accept: 'application/json',
|
|
1591
|
+
authorization: `Bearer ${options.sessionToken}`,
|
|
1592
|
+
'content-type': 'application/json',
|
|
1593
|
+
},
|
|
1594
|
+
body: JSON.stringify(body),
|
|
1595
|
+
},
|
|
1596
|
+
'local-runner session token exchange'
|
|
1597
|
+
);
|
|
1598
|
+
const payload: unknown = await response.json();
|
|
1599
|
+
|
|
1600
|
+
if (
|
|
1601
|
+
!response.ok ||
|
|
1602
|
+
typeof payload !== 'object' ||
|
|
1603
|
+
payload === null ||
|
|
1604
|
+
Array.isArray(payload) ||
|
|
1605
|
+
(payload as { ok?: unknown }).ok !== true ||
|
|
1606
|
+
typeof (payload as { access_token?: unknown }).access_token !== 'string'
|
|
1607
|
+
) {
|
|
1608
|
+
const error =
|
|
1609
|
+
typeof payload === 'object' &&
|
|
1610
|
+
payload !== null &&
|
|
1611
|
+
!Array.isArray(payload) &&
|
|
1612
|
+
typeof (payload as { error?: unknown }).error === 'string'
|
|
1613
|
+
? (payload as { error: string }).error
|
|
1614
|
+
: 'unknown';
|
|
1615
|
+
throw new Error(
|
|
1616
|
+
`local-runner session token exchange failed: ${response.status}:${error}`
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
return (payload as { access_token: string }).access_token;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function backendHttpUrl(backendUrl: string, pathname: string): string {
|
|
1624
|
+
const url = new URL(backendUrl);
|
|
1625
|
+
|
|
1626
|
+
switch (url.protocol) {
|
|
1627
|
+
case 'http:':
|
|
1628
|
+
case 'https:':
|
|
1629
|
+
break;
|
|
1630
|
+
case 'ws:':
|
|
1631
|
+
url.protocol = 'http:';
|
|
1632
|
+
break;
|
|
1633
|
+
case 'wss:':
|
|
1634
|
+
url.protocol = 'https:';
|
|
1635
|
+
break;
|
|
1636
|
+
default:
|
|
1637
|
+
throw new Error(
|
|
1638
|
+
'--backend-url must use http:, https:, ws:, or wss: protocol'
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
url.pathname = pathname;
|
|
1643
|
+
url.search = '';
|
|
1644
|
+
url.hash = '';
|
|
1645
|
+
return url.toString();
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
function backendProbeJoinPayload(options: BackendReplayOptions): JsonObject {
|
|
1649
|
+
return {
|
|
1650
|
+
clientName: 'kandan-local-codex-runner',
|
|
1651
|
+
clientId: 'agent-backend-replay-probe',
|
|
1652
|
+
runnerPid: process.pid,
|
|
1653
|
+
version: linzumiCliVersion,
|
|
1654
|
+
cwd: options.cwd,
|
|
1655
|
+
workspace: options.workspace ?? null,
|
|
1656
|
+
channel: options.channel ?? null,
|
|
1657
|
+
capabilities: {
|
|
1658
|
+
codexAppServer: true,
|
|
1659
|
+
codexRemoteTui: true,
|
|
1660
|
+
client_message_ids: true,
|
|
1661
|
+
commander_pipeline: 'pipeline',
|
|
1662
|
+
agentProviders: ['codex', 'claude-code'],
|
|
1663
|
+
agentReplay: true,
|
|
1664
|
+
},
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
async function manifestSummary(
|
|
1669
|
+
fixturePath: string
|
|
1670
|
+
): Promise<AgentReplayManifestSummary | undefined> {
|
|
1671
|
+
const loaded = await loadAgentReplayManifestForFixture(fixturePath);
|
|
1672
|
+
|
|
1673
|
+
if (loaded === undefined) {
|
|
1674
|
+
return undefined;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
if (loaded.type === 'error') {
|
|
1678
|
+
throw new Error(JSON.stringify(loaded.error));
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
return summarizeAgentReplayManifest(loaded.manifest);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
async function manifestReadiness(
|
|
1685
|
+
fixturePath: string
|
|
1686
|
+
): Promise<readonly AgentReplayFeatureReadiness[] | undefined> {
|
|
1687
|
+
const loaded = await loadAgentReplayManifestForFixture(fixturePath);
|
|
1688
|
+
|
|
1689
|
+
if (loaded === undefined) {
|
|
1690
|
+
return undefined;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
if (loaded.type === 'error') {
|
|
1694
|
+
throw new Error(JSON.stringify(loaded.error));
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
return evaluateAgentReplayFeatureReadiness(loaded.manifest);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
type RequiredFeatureGate =
|
|
1701
|
+
| {
|
|
1702
|
+
readonly status: 'passed';
|
|
1703
|
+
readonly featureId: string;
|
|
1704
|
+
readonly readiness: AgentReplayFeatureReadiness;
|
|
1705
|
+
}
|
|
1706
|
+
| {
|
|
1707
|
+
readonly status: 'failed';
|
|
1708
|
+
readonly featureId: string;
|
|
1709
|
+
readonly reason: string;
|
|
1710
|
+
readonly readiness?: AgentReplayFeatureReadiness | undefined;
|
|
1711
|
+
};
|
|
1712
|
+
|
|
1713
|
+
function evaluateRequiredFeatureGate(
|
|
1714
|
+
featureId: string | undefined,
|
|
1715
|
+
readiness: readonly AgentReplayFeatureReadiness[] | undefined
|
|
1716
|
+
): RequiredFeatureGate | { readonly status: 'not-required' } {
|
|
1717
|
+
if (featureId === undefined) {
|
|
1718
|
+
return { status: 'not-required' };
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
if (readiness === undefined) {
|
|
1722
|
+
return {
|
|
1723
|
+
status: 'failed',
|
|
1724
|
+
featureId,
|
|
1725
|
+
reason: '--require-feature needs a neighboring fixture manifest',
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
const feature = readiness.find((row) => row.featureId === featureId);
|
|
1730
|
+
|
|
1731
|
+
if (feature === undefined) {
|
|
1732
|
+
return {
|
|
1733
|
+
status: 'failed',
|
|
1734
|
+
featureId,
|
|
1735
|
+
reason: `feature ${featureId} is not present in the fixture manifest`,
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
switch (feature.status) {
|
|
1740
|
+
case 'implemented':
|
|
1741
|
+
case 'ready-to-implement':
|
|
1742
|
+
return { status: 'passed', featureId, readiness: feature };
|
|
1743
|
+
case 'blocked':
|
|
1744
|
+
case 'missing-proof':
|
|
1745
|
+
case 'not-started':
|
|
1746
|
+
return {
|
|
1747
|
+
status: 'failed',
|
|
1748
|
+
featureId,
|
|
1749
|
+
readiness: feature,
|
|
1750
|
+
reason: feature.reason,
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function argValue(args: readonly string[], name: string): string | undefined {
|
|
1756
|
+
const index = args.indexOf(name);
|
|
1757
|
+
|
|
1758
|
+
if (index === -1) {
|
|
1759
|
+
return undefined;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
const value = args[index + 1];
|
|
1763
|
+
|
|
1764
|
+
if (value === undefined) {
|
|
1765
|
+
throw new Error(`${name} requires a value`);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
return value;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function printHelp(): void {
|
|
1772
|
+
process.stdout.write(
|
|
1773
|
+
`Usage: pnpm run replay:agent-backend -- --fixture <path> [options]\n\nOptions:\n --dry-run Validate fixture/manifest/diagnostics without backend connection\n --probe-backend Also join the real Phoenix local_runner channel, then exit\n --provider auto|codex|claude-code Replay provider. Default: auto\n --speed immediate|realtime|<n>x|<n>ms\n Replay speed. Default: immediate\n --backend-url <url> Local Phoenix backend URL; required by --probe-backend/replay\n --setup-fixture Create a throwaway fixture user/workspace/channel first; requires local fixture registration\n --setup-label <label> Prefix for throwaway fixture setup. Default: agent-backend-replay\n --workspace <slug> Target workspace slug for session-token exchange and future replay\n --channel <slug> Target channel slug for token scoping and future replay\n --thread-id <id> Target thread id for the future backend replay slice\n --runner-id <id> Local runner id for backend probing. Default: generated\n --cwd <path> Cwd advertised in backend probe/replay. Default: current directory\n --work-description <text> Work description sent through the normal start mutation\n --token <token> Local-runner token; also reads LINZUMI_AGENT_BACKEND_REPLAY_TOKEN\n --session-token <token> Browser/session token to exchange for a local-runner token; also reads LINZUMI_AGENT_BACKEND_REPLAY_SESSION_TOKEN\n --out-dir <path> Artifact directory for summary.json. Default: agent-backend-replay-output\n --require-feature <featureId> Fail before backend setup unless manifest readiness says the feature is ready\n --oracle-feature <featureId> Run feature-specific backend oracles without using readiness as a preflight gate\n --no-summary Do not write summary.json; stdout JSON is still printed\n\nExamples:\n pnpm --filter @linzumi/cli run replay:agent-backend -- --fixture test/fixtures/agent-raw-replay/real-probe/claude.ndjson --provider claude-code --dry-run\n pnpm --filter @linzumi/cli run replay:agent-backend -- --fixture test/fixtures/agent-raw-replay/real-probe/claude.ndjson --provider claude-code --probe-backend --backend-url http://127.0.0.1:4140 --workspace default --session-token <browser-session-token>\n pnpm --filter @linzumi/cli run replay:agent-backend -- --fixture test/fixtures/agent-raw-replay/real-probe/claude.ndjson --provider claude-code --backend-url http://127.0.0.1:4140 --setup-fixture\n`
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
main().catch((error) => {
|
|
1778
|
+
process.stderr.write(
|
|
1779
|
+
`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`
|
|
1780
|
+
);
|
|
1781
|
+
process.exitCode = 1;
|
|
1782
|
+
});
|