@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.
@@ -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
+ });