@shardworks/spider-apparatus 0.1.226 → 0.1.227

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.
@@ -4,11 +4,17 @@
4
4
  * Contributes:
5
5
  * - GET /api/spider/config — aggregated snapshot of Spider config
6
6
  * - GET /api/spider/session-transcript — session transcript and status
7
- * - GET /api/spider/session-stream — SSE stream of real-time session chunks
8
7
  *
9
8
  * Does NOT import from @shardworks/oculus-apparatus to avoid a circular
10
9
  * package dependency. The route shape is compatible with RouteContribution
11
10
  * from the Oculus types.
11
+ *
12
+ * The former /api/spider/session-stream (SSE) route was removed when the
13
+ * Spider UI switched to uniform 2 s polling of /api/spider/session-transcript.
14
+ * The SSE path only worked for in-process (attached) sessions and silently
15
+ * dropped data for detached (cross-process) sessions because the Animator's
16
+ * subscribeToSession cannot bridge processes. Polling the Stacks-backed
17
+ * transcript works uniformly for both attached and detached sessions.
12
18
  */
13
19
  import type { Context } from 'hono';
14
20
  export declare const spiderRoutes: ({
@@ -48,6 +54,15 @@ export declare const spiderRoutes: ({
48
54
  } | {
49
55
  method: string;
50
56
  path: string;
51
- handler: (c: Context) => Promise<Response>;
57
+ handler: (c: Context) => Promise<(Response & import("hono").TypedResponse<{
58
+ error: string;
59
+ }, 400, "json">) | (Response & import("hono").TypedResponse<{
60
+ error: string;
61
+ }, 404, "json">) | (Response & import("hono").TypedResponse<{
62
+ messages: {
63
+ [x: string]: import("hono/utils/types").JSONValue;
64
+ }[];
65
+ sessionStatus: "completed" | "failed" | "cancelled" | "timeout" | "pending" | "running";
66
+ }, import("hono/utils/http-status").ContentfulStatusCode, "json">)>;
52
67
  })[];
53
68
  //# sourceMappingURL=oculus-routes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"oculus-routes.d.ts","sourceRoot":"","sources":["../src/oculus-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAepC,eAAO,MAAM,YAAY;;;iBAIR,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAiDD,OAAO;IAkF7B,CAAC"}
1
+ {"version":3,"file":"oculus-routes.d.ts","sourceRoot":"","sources":["../src/oculus-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAcpC,eAAO,MAAM,YAAY;;;iBAIR,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgBD,OAAO;;;;;;;;;;IA8B7B,CAAC"}
@@ -4,13 +4,18 @@
4
4
  * Contributes:
5
5
  * - GET /api/spider/config — aggregated snapshot of Spider config
6
6
  * - GET /api/spider/session-transcript — session transcript and status
7
- * - GET /api/spider/session-stream — SSE stream of real-time session chunks
8
7
  *
9
8
  * Does NOT import from @shardworks/oculus-apparatus to avoid a circular
10
9
  * package dependency. The route shape is compatible with RouteContribution
11
10
  * from the Oculus types.
11
+ *
12
+ * The former /api/spider/session-stream (SSE) route was removed when the
13
+ * Spider UI switched to uniform 2 s polling of /api/spider/session-transcript.
14
+ * The SSE path only worked for in-process (attached) sessions and silently
15
+ * dropped data for detached (cross-process) sessions because the Animator's
16
+ * subscribeToSession cannot bridge processes. Polling the Stacks-backed
17
+ * transcript works uniformly for both attached and detached sessions.
12
18
  */
13
- import { streamSSE } from 'hono/streaming';
14
19
  import { guild } from '@shardworks/nexus-core';
15
20
  export const spiderRoutes = [
16
21
  {
@@ -45,8 +50,8 @@ export const spiderRoutes = [
45
50
  }
46
51
  // Read transcript regardless of status. Detached sessions (babysitter)
47
52
  // write transcript chunks to SQLite incrementally while the session is
48
- // still 'running' or 'pending'; the client polls this endpoint to
49
- // refresh the engine view's session log in flight.
53
+ // still 'running' or 'pending'; the client polls this endpoint every
54
+ // 2 s to refresh the engine view's session log in flight.
50
55
  const transcriptsBook = stacks.readBook('animator', 'transcripts');
51
56
  const transcript = await transcriptsBook.get(sessionId);
52
57
  return c.json({
@@ -55,80 +60,5 @@ export const spiderRoutes = [
55
60
  });
56
61
  },
57
62
  },
58
- {
59
- method: 'GET',
60
- path: '/api/spider/session-stream',
61
- handler: async (c) => {
62
- const sessionId = c.req.query('sessionId');
63
- if (!sessionId) {
64
- return c.json({ error: 'sessionId is required' }, 400);
65
- }
66
- const g = guild();
67
- const stacks = g.apparatus('stacks');
68
- const sessionsBook = stacks.readBook('animator', 'sessions');
69
- const session = await sessionsBook.get(sessionId);
70
- if (!session) {
71
- return c.json({ error: 'Session not found' }, 404);
72
- }
73
- // For already-completed sessions, stream the full transcript and close.
74
- if (session.status !== 'running') {
75
- const transcriptsBook = stacks.readBook('animator', 'transcripts');
76
- const transcript = await transcriptsBook.get(sessionId);
77
- return streamSSE(c, async (stream) => {
78
- await stream.writeSSE({
79
- event: 'transcript',
80
- data: JSON.stringify({ messages: transcript?.messages ?? [] }),
81
- });
82
- await stream.writeSSE({
83
- event: 'done',
84
- data: JSON.stringify({ status: session.status }),
85
- });
86
- });
87
- }
88
- // For running sessions, subscribe to the Animator's in-process broadcaster.
89
- const animator = g.apparatus('animator');
90
- const chunkStream = animator.subscribeToSession(sessionId);
91
- if (!chunkStream) {
92
- // The session is marked running in Stacks but has no in-memory broadcaster
93
- // (e.g. a server restart happened). Return an empty stream so the UI can
94
- // show a meaningful "no data" state and fall back gracefully.
95
- return streamSSE(c, async (stream) => {
96
- await stream.writeSSE({
97
- event: 'done',
98
- data: JSON.stringify({ status: 'running', noStream: true }),
99
- });
100
- });
101
- }
102
- return streamSSE(c, async (stream) => {
103
- try {
104
- for await (const chunk of chunkStream) {
105
- await stream.writeSSE({
106
- event: 'chunk',
107
- data: JSON.stringify(chunk),
108
- });
109
- }
110
- // All chunks consumed — session has ended. Fetch and emit final transcript.
111
- const transcriptsBook = stacks.readBook('animator', 'transcripts');
112
- const transcript = await transcriptsBook.get(sessionId);
113
- await stream.writeSSE({
114
- event: 'transcript',
115
- data: JSON.stringify({ messages: transcript?.messages ?? [] }),
116
- });
117
- const finalSession = await sessionsBook.get(sessionId);
118
- await stream.writeSSE({
119
- event: 'done',
120
- data: JSON.stringify({ status: finalSession?.status ?? 'completed' }),
121
- });
122
- }
123
- catch (err) {
124
- const message = err instanceof Error ? err.message : String(err);
125
- await stream.writeSSE({
126
- event: 'error',
127
- data: JSON.stringify({ error: message }),
128
- });
129
- }
130
- });
131
- },
132
- },
133
63
  ];
134
64
  //# sourceMappingURL=oculus-routes.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"oculus-routes.js","sourceRoot":"","sources":["../src/oculus-routes.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"oculus-routes.js","sourceRoot":"","sources":["../src/oculus-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAC;AAa/C,MAAM,CAAC,MAAM,YAAY,GAAG;IAC1B;QACE,MAAM,EAAE,KAAK;QACb,IAAI,EAAE,oBAAoB;QAC1B,OAAO,EAAE,CAAC,CAAU,EAAE,EAAE;YACtB,MAAM,CAAC,GAAG,KAAK,EAAE,CAAC;YAClB,MAAM,UAAU,GAAG,CAAC,CAAC,SAAS,CAAgB,YAAY,CAAC,CAAC;YAC5D,MAAM,MAAM,GAAG,CAAC,CAAC,SAAS,CAAY,QAAQ,CAAC,CAAC;YAEhD,OAAO,CAAC,CAAC,IAAI,CAAC;gBACZ,SAAS,EAAE,MAAM,CAAC,aAAa,EAAE;gBACjC,gBAAgB,EAAE,MAAM,CAAC,oBAAoB,EAAE;gBAC/C,aAAa,EAAE,UAAU,CAAC,iBAAiB,EAAE;gBAC7C,UAAU,EAAE,MAAM,CAAC,cAAc,EAAE;aACpC,CAAC,CAAC;QACL,CAAC;KACF;IACD;QACE,MAAM,EAAE,KAAK;QACb,IAAI,EAAE,gCAAgC;QACtC,OAAO,EAAE,KAAK,EAAE,CAAU,EAAE,EAAE;YAC5B,MAAM,SAAS,GAAG,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;YAE3C,IAAI,CAAC,SAAS,EAAE,CAAC;gBACf,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,uBAAuB,EAAE,EAAE,GAAG,CAAC,CAAC;YACzD,CAAC;YAED,MAAM,CAAC,GAAG,KAAK,EAAE,CAAC;YAClB,MAAM,MAAM,GAAG,CAAC,CAAC,SAAS,CAAY,QAAQ,CAAC,CAAC;YAEhD,MAAM,YAAY,GAAG,MAAM,CAAC,QAAQ,CAAa,UAAU,EAAE,UAAU,CAAC,CAAC;YACzE,MAAM,OAAO,GAAG,MAAM,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;YAElD,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mBAAmB,EAAE,EAAE,GAAG,CAAC,CAAC;YACrD,CAAC;YAED,uEAAuE;YACvE,uEAAuE;YACvE,qEAAqE;YACrE,0DAA0D;YAC1D,MAAM,eAAe,GAAG,MAAM,CAAC,QAAQ,CAAkB,UAAU,EAAE,aAAa,CAAC,CAAC;YACpF,MAAM,UAAU,GAAG,MAAM,eAAe,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;YAExD,OAAO,CAAC,CAAC,IAAI,CAAC;gBACZ,QAAQ,EAAE,UAAU,EAAE,QAAQ,IAAI,EAAE;gBACpC,aAAa,EAAE,OAAO,CAAC,MAAM;aAC9B,CAAC,CAAC;QACL,CAAC;KACF;CACF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardworks/spider-apparatus",
3
- "version": "0.1.226",
3
+ "version": "0.1.227",
4
4
  "license": "ISC",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,17 +22,17 @@
22
22
  "hono": "^4.7.11",
23
23
  "yaml": "^2.0.0",
24
24
  "zod": "4.3.6",
25
- "@shardworks/fabricator-apparatus": "0.1.226",
26
- "@shardworks/stacks-apparatus": "0.1.226",
27
- "@shardworks/clerk-apparatus": "0.1.226",
28
- "@shardworks/animator-apparatus": "0.1.226",
29
- "@shardworks/tools-apparatus": "0.1.226",
30
- "@shardworks/loom-apparatus": "0.1.226",
31
- "@shardworks/codexes-apparatus": "0.1.226"
25
+ "@shardworks/fabricator-apparatus": "0.1.227",
26
+ "@shardworks/stacks-apparatus": "0.1.227",
27
+ "@shardworks/tools-apparatus": "0.1.227",
28
+ "@shardworks/codexes-apparatus": "0.1.227",
29
+ "@shardworks/animator-apparatus": "0.1.227",
30
+ "@shardworks/loom-apparatus": "0.1.227",
31
+ "@shardworks/clerk-apparatus": "0.1.227"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "25.5.0",
35
- "@shardworks/nexus-core": "0.1.226"
35
+ "@shardworks/nexus-core": "0.1.227"
36
36
  },
37
37
  "files": [
38
38
  "dist",
@@ -490,122 +490,296 @@ describe('spider.js engine-detail stable-skeleton + updater', () => {
490
490
  });
491
491
  });
492
492
 
493
- // ── SSE stream lifecycle decoupled from rig polling ─────────────────────
493
+ // ── SSE removal regression guard ────────────────────────────────────────
494
494
 
495
- describe('spider.js SSE lifecycle decoupling', () => {
496
- it('declares streamSessionId in module scope', () => {
495
+ describe('spider.js SSE removal', () => {
496
+ // The SSE path was removed because animator.subscribeToSession does not
497
+ // work across processes (detached sessions return null). The UI now
498
+ // polls /api/spider/session-transcript uniformly for all sessions.
499
+ // These guards keep the SSE code from creeping back in.
500
+
501
+ it('has no EventSource constructor in the UI code', () => {
502
+ assert.doesNotMatch(
503
+ spiderJs,
504
+ /new\s+EventSource/,
505
+ 'UI should not construct EventSource (SSE was removed)',
506
+ );
507
+ });
508
+
509
+ it('does not define streamSessionId or streamDone state', () => {
510
+ assert.doesNotMatch(spiderJs, /\bstreamSessionId\b/, 'streamSessionId should not exist');
511
+ assert.doesNotMatch(spiderJs, /\bstreamDone\b/, 'streamDone should not exist');
512
+ });
513
+
514
+ it('does not define ensureSessionStream / openSessionStream / stopSessionStream', () => {
515
+ assert.doesNotMatch(spiderJs, /function\s+ensureSessionStream/, 'ensureSessionStream removed');
516
+ assert.doesNotMatch(spiderJs, /function\s+openSessionStream/, 'openSessionStream removed');
517
+ assert.doesNotMatch(spiderJs, /function\s+stopSessionStream/, 'stopSessionStream removed');
518
+ });
519
+
520
+ it('does not reference /api/spider/session-stream', () => {
521
+ assert.doesNotMatch(
522
+ spiderJs,
523
+ /\/api\/spider\/session-stream/,
524
+ 'UI should not reference the SSE endpoint',
525
+ );
526
+ });
527
+ });
528
+
529
+ // ── Session transcript polling ──────────────────────────────────────────
530
+
531
+ describe('spider.js session transcript polling', () => {
532
+ it('declares transcriptPollSessionId in module scope', () => {
497
533
  assert.match(
498
534
  spiderJs,
499
- /var streamSessionId\s*=\s*null/,
500
- 'should track streamSessionId in module scope',
535
+ /var transcriptPollSessionId\s*=\s*null/,
536
+ 'should track transcriptPollSessionId in module scope',
501
537
  );
502
538
  });
503
539
 
504
- it('declares streamDone in module scope (hoisted out of showEngineDetail)', () => {
505
- // The streamDone flag must survive the function boundary so the SSE
506
- // error handler can still reference it after stopSessionStream nulls
507
- // out the EventSource reference.
540
+ it('declares TRANSCRIPT_POLL_INTERVAL at 2000 ms', () => {
508
541
  assert.match(
509
542
  spiderJs,
510
- /^\s*var streamDone\s*=\s*false;?\s*$/m,
511
- 'should declare streamDone in module scope',
543
+ /var TRANSCRIPT_POLL_INTERVAL\s*=\s*2000/,
544
+ 'should set the polling interval to 2000 ms',
512
545
  );
513
546
  });
514
547
 
515
- it('defines ensureSessionStream that compares against streamSessionId', () => {
548
+ it('defines startSessionTranscriptPoll that dedupes on sessionId', () => {
516
549
  assert.match(
517
550
  spiderJs,
518
- /function ensureSessionStream\(/,
519
- 'should define ensureSessionStream',
551
+ /function startSessionTranscriptPoll\(/,
552
+ 'should define startSessionTranscriptPoll',
520
553
  );
521
- const ensureBlock = spiderJs.match(
522
- /function ensureSessionStream[\s\S]*?(?=\n function )/,
554
+ const startBlock = spiderJs.match(
555
+ /function startSessionTranscriptPoll[\s\S]*?(?=\n function )/,
523
556
  );
524
- assert.ok(ensureBlock, 'should find ensureSessionStream body');
557
+ assert.ok(startBlock, 'should find startSessionTranscriptPoll body');
525
558
  assert.match(
526
- ensureBlock[0],
527
- /streamSessionId/,
528
- 'ensureSessionStream should compare against streamSessionId',
559
+ startBlock[0],
560
+ /sessionId\s*===\s*transcriptPollSessionId/,
561
+ 'startSessionTranscriptPoll should early-return when id is unchanged',
529
562
  );
530
563
  });
531
564
 
532
- it('showEngineDetail calls ensureSessionStream (not raw EventSource)', () => {
533
- const showBlock = spiderJs.match(
534
- /function showEngineDetail\(engine\)[\s\S]*?(?=\n function )/,
565
+ it('defines stopSessionTranscriptPoll that clears transcriptPollSessionId', () => {
566
+ assert.match(
567
+ spiderJs,
568
+ /function stopSessionTranscriptPoll\(/,
569
+ 'should define stopSessionTranscriptPoll',
535
570
  );
536
- assert.ok(showBlock, 'should find showEngineDetail');
571
+ const stopBlock = spiderJs.match(
572
+ /function stopSessionTranscriptPoll[\s\S]*?(?=\n function )/,
573
+ );
574
+ assert.ok(stopBlock, 'should find stopSessionTranscriptPoll body');
537
575
  assert.match(
538
- showBlock[0],
539
- /ensureSessionStream\(/,
540
- 'click path should go through ensureSessionStream',
576
+ stopBlock[0],
577
+ /transcriptPollSessionId\s*=\s*null/,
578
+ 'stopSessionTranscriptPoll should null out transcriptPollSessionId',
541
579
  );
542
- assert.doesNotMatch(
543
- showBlock[0],
544
- /new EventSource/,
545
- 'click path should not directly construct EventSource (delegated to openSessionStream)',
580
+ assert.match(
581
+ stopBlock[0],
582
+ /stopSessionPoll\(/,
583
+ 'stopSessionTranscriptPoll should clear the underlying timer',
546
584
  );
547
585
  });
548
586
 
549
- it('stopSessionStream clears streamSessionId so re-opens dedupe correctly', () => {
550
- const stopBlock = spiderJs.match(
551
- /function stopSessionStream\(\)[\s\S]*?(?=\n function )/,
587
+ it('defines fetchAndRenderTranscript that calls /api/spider/session-transcript', () => {
588
+ assert.match(
589
+ spiderJs,
590
+ /function fetchAndRenderTranscript\(/,
591
+ 'should define fetchAndRenderTranscript',
592
+ );
593
+ const fetchBlock = spiderJs.match(
594
+ /function fetchAndRenderTranscript[\s\S]*?(?=\n function )/,
552
595
  );
553
- assert.ok(stopBlock, 'should find stopSessionStream');
596
+ assert.ok(fetchBlock, 'should find fetchAndRenderTranscript body');
554
597
  assert.match(
555
- stopBlock[0],
556
- /streamSessionId\s*=\s*null/,
557
- 'stopSessionStream should null out streamSessionId',
598
+ fetchBlock[0],
599
+ /\/api\/spider\/session-transcript\?sessionId=/,
600
+ 'fetchAndRenderTranscript should hit the transcript endpoint',
558
601
  );
559
602
  });
560
- });
561
603
 
562
- // ── Transcript scroll preservation on all write paths ───────────────────
604
+ it('fetchAndRenderTranscript stops polling on terminal session status', () => {
605
+ const fetchBlock = spiderJs.match(
606
+ /function fetchAndRenderTranscript[\s\S]*?(?=\n function )/,
607
+ );
608
+ assert.ok(fetchBlock, 'should find fetchAndRenderTranscript body');
609
+ assert.match(
610
+ fetchBlock[0],
611
+ /stopSessionTranscriptPoll\(/,
612
+ 'should tear down polling when the session reaches a terminal status',
613
+ );
614
+ });
563
615
 
564
- describe('spider.js transcript scroll preservation', () => {
565
- it('SSE chunk handler captures atBottom before mutating textarea', () => {
566
- const chunkBlock = spiderJs.match(
567
- /addEventListener\('chunk',\s*function[\s\S]*?\}\);/,
616
+ it('fetchAndRenderTranscript captures atBottom before replacing textarea value', () => {
617
+ const fetchBlock = spiderJs.match(
618
+ /function fetchAndRenderTranscript[\s\S]*?(?=\n function )/,
568
619
  );
569
- assert.ok(chunkBlock, 'should find chunk handler');
620
+ assert.ok(fetchBlock, 'should find fetchAndRenderTranscript body');
570
621
  assert.match(
571
- chunkBlock[0],
622
+ fetchBlock[0],
572
623
  /var atBottom\s*=/,
573
- 'chunk handler should capture atBottom before mutation',
624
+ 'should capture atBottom before mutating the textarea',
574
625
  );
575
626
  assert.match(
576
- chunkBlock[0],
627
+ fetchBlock[0],
577
628
  /if\s*\(atBottom\)/,
578
- 'chunk handler should restore scroll only when atBottom was true',
629
+ 'should restore scroll only when atBottom was true',
579
630
  );
580
631
  });
581
632
 
582
- it('SSE transcript handler captures atBottom before replacing textarea value', () => {
583
- const transcriptBlock = spiderJs.match(
584
- /addEventListener\('transcript',\s*function[\s\S]*?\}\);/,
633
+ it('updateEngineDetail drives transcript polling via startSessionTranscriptPoll', () => {
634
+ const updaterBlock = spiderJs.match(
635
+ /function updateEngineDetail\(engine\)[\s\S]*?(?=\n function )/,
585
636
  );
586
- assert.ok(transcriptBlock, 'should find transcript handler');
637
+ assert.ok(updaterBlock, 'should find updateEngineDetail');
587
638
  assert.match(
588
- transcriptBlock[0],
589
- /var atBottom\s*=/,
590
- 'transcript handler should capture atBottom before mutation',
639
+ updaterBlock[0],
640
+ /startSessionTranscriptPoll\(/,
641
+ 'updateEngineDetail should call startSessionTranscriptPoll every rig poll (dedup handles no-ops)',
642
+ );
643
+ });
644
+
645
+ it('navigating away stops transcript polling', () => {
646
+ const backBlock = spiderJs.match(
647
+ /function backToList\(\)[\s\S]*?(?=\n function )/,
591
648
  );
649
+ assert.ok(backBlock, 'should find backToList');
592
650
  assert.match(
593
- transcriptBlock[0],
594
- /if\s*\(atBottom\)/,
595
- 'transcript handler should restore scroll only when atBottom was true',
651
+ backBlock[0],
652
+ /stopSessionTranscriptPoll\(/,
653
+ 'backToList should stop transcript polling',
654
+ );
655
+ const showRigBlock = spiderJs.match(
656
+ /function showRigDetail\(rig\)[\s\S]*?(?=\n function )/,
657
+ );
658
+ assert.ok(showRigBlock, 'should find showRigDetail');
659
+ assert.match(
660
+ showRigBlock[0],
661
+ /stopSessionTranscriptPoll\(/,
662
+ 'showRigDetail should stop any prior transcript poll before switching rigs',
596
663
  );
597
664
  });
665
+ });
598
666
 
599
- it('noStream polling fallback also uses the atBottom pattern', () => {
600
- // The noStream path's atBottom guard predates this fix and must remain.
601
- const doneBlock = spiderJs.match(
602
- /addEventListener\('done',\s*function[\s\S]*?if \(data\.noStream[\s\S]*?\}\);\s*$/m,
667
+ // ── Elapsed ticker ──────────────────────────────────────────────────────
668
+
669
+ describe('spider.js elapsed ticker', () => {
670
+ it('declares elapsedTimer and elapsedTimerStartedAt in module scope', () => {
671
+ assert.match(
672
+ spiderJs,
673
+ /var elapsedTimer\s*=\s*null/,
674
+ 'should declare elapsedTimer state',
603
675
  );
604
- assert.ok(doneBlock, 'should find done handler with noStream branch');
605
676
  assert.match(
606
- doneBlock[0],
607
- /var atBottom\s*=/,
608
- 'noStream fallback should retain its atBottom capture',
677
+ spiderJs,
678
+ /var elapsedTimerStartedAt\s*=\s*null/,
679
+ 'should declare elapsedTimerStartedAt state',
680
+ );
681
+ });
682
+
683
+ it('declares ELAPSED_TICK_INTERVAL at 1000 ms', () => {
684
+ assert.match(
685
+ spiderJs,
686
+ /var ELAPSED_TICK_INTERVAL\s*=\s*1000/,
687
+ 'elapsed ticker should tick every 1000 ms',
688
+ );
689
+ });
690
+
691
+ it('defines startElapsedTimer that dedupes on startedAt', () => {
692
+ assert.match(
693
+ spiderJs,
694
+ /function startElapsedTimer\(/,
695
+ 'should define startElapsedTimer',
696
+ );
697
+ const startBlock = spiderJs.match(
698
+ /function startElapsedTimer[\s\S]*?(?=\n function )/,
699
+ );
700
+ assert.ok(startBlock, 'should find startElapsedTimer body');
701
+ assert.match(
702
+ startBlock[0],
703
+ /elapsedTimerStartedAt\s*===\s*startedAt/,
704
+ 'startElapsedTimer should early-return when startedAt is unchanged',
705
+ );
706
+ assert.match(
707
+ startBlock[0],
708
+ /formatElapsed\(startedAt,\s*new Date\(\)\.toISOString\(\)\)/,
709
+ 'startElapsedTimer tick should call formatElapsed with now()',
710
+ );
711
+ });
712
+
713
+ it('defines stopElapsedTimer that clears the interval', () => {
714
+ assert.match(
715
+ spiderJs,
716
+ /function stopElapsedTimer\(/,
717
+ 'should define stopElapsedTimer',
718
+ );
719
+ const stopBlock = spiderJs.match(
720
+ /function stopElapsedTimer[\s\S]*?(?=\n function )/,
721
+ );
722
+ assert.ok(stopBlock, 'should find stopElapsedTimer body');
723
+ assert.match(
724
+ stopBlock[0],
725
+ /clearInterval\(elapsedTimer\)/,
726
+ 'stopElapsedTimer should clear the interval',
727
+ );
728
+ });
729
+
730
+ it('updateEngineDetail starts the ticker for running engines with a startedAt', () => {
731
+ const updaterBlock = spiderJs.match(
732
+ /function updateEngineDetail\(engine\)[\s\S]*?(?=\n function )/,
733
+ );
734
+ assert.ok(updaterBlock, 'should find updateEngineDetail');
735
+ assert.match(
736
+ updaterBlock[0],
737
+ /engine\.status\s*===\s*'running'\s*&&\s*engine\.startedAt[\s\S]*?startElapsedTimer\(/,
738
+ 'updateEngineDetail should start the elapsed ticker for running engines',
739
+ );
740
+ });
741
+
742
+ it('updateEngineDetail stops the ticker when engine completes', () => {
743
+ const updaterBlock = spiderJs.match(
744
+ /function updateEngineDetail\(engine\)[\s\S]*?(?=\n function )/,
745
+ );
746
+ assert.ok(updaterBlock, 'should find updateEngineDetail');
747
+ assert.match(
748
+ updaterBlock[0],
749
+ /engine\.status\s*===\s*'completed'[\s\S]*?stopElapsedTimer\(/,
750
+ 'updateEngineDetail should stop the elapsed ticker on completion',
751
+ );
752
+ });
753
+
754
+ it('static "running…" placeholder is replaced by the live ticker', () => {
755
+ // The old implementation wrote a literal "running…" span. Now the
756
+ // ticker writes a live elapsed value every second. Regression guard
757
+ // against re-introducing the static placeholder.
758
+ assert.doesNotMatch(
759
+ spiderJs,
760
+ /<span class="elapsed-running">running\\u2026<\/span>/,
761
+ 'static "running…" placeholder should be gone',
762
+ );
763
+ });
764
+
765
+ it('navigating away stops the elapsed ticker', () => {
766
+ const backBlock = spiderJs.match(
767
+ /function backToList\(\)[\s\S]*?(?=\n function )/,
768
+ );
769
+ assert.ok(backBlock, 'should find backToList');
770
+ assert.match(
771
+ backBlock[0],
772
+ /stopElapsedTimer\(/,
773
+ 'backToList should stop the elapsed ticker',
774
+ );
775
+ const showRigBlock = spiderJs.match(
776
+ /function showRigDetail\(rig\)[\s\S]*?(?=\n function )/,
777
+ );
778
+ assert.ok(showRigBlock, 'should find showRigDetail');
779
+ assert.match(
780
+ showRigBlock[0],
781
+ /stopElapsedTimer\(/,
782
+ 'showRigDetail should stop any prior ticker before switching rigs',
609
783
  );
610
784
  });
611
785
  });
@@ -16,20 +16,27 @@
16
16
  var currentStatusFilter = '';
17
17
  var writLookup = {};
18
18
  var sessionPollTimer = null;
19
- var sessionEventSource = null;
20
19
  var selectedTemplateName = null;
21
20
  var rigListPollTimer = null;
22
21
  var currentRigPollTimer = null;
23
22
 
24
- // SSE lifecycle state — decoupled from the rig-detail render path.
25
- // streamSessionId tracks which sessionId the active EventSource was opened
26
- // for, so we can dedupe re-opens across rig polls and only re-open when the
27
- // tracked id actually changes (engine switch or pending running).
28
- var streamSessionId = null;
29
- // streamDone is hoisted out of showEngineDetail so the EventSource error
30
- // handler (which fires asynchronously after a clean close) can still see
31
- // that the stream ended normally and skip the spurious "disconnected" badge.
32
- var streamDone = false;
23
+ // Session transcript polling state.
24
+ // transcriptPollSessionId tracks the sessionId the current 2 s polling
25
+ // loop is bound to. startSessionTranscriptPoll dedupes against this so
26
+ // repeat calls with the same id are no-ops (letting updateEngineDetail
27
+ // call it every rig poll without tearing down the timer). Cleared by
28
+ // stopSessionTranscriptPoll. Nothing else should touch it.
29
+ var transcriptPollSessionId = null;
30
+ var TRANSCRIPT_POLL_INTERVAL = 2000;
31
+
32
+ // Elapsed ticker — for a running engine, we refresh the Elapsed field
33
+ // locally every second so the UI has a visible live pulse without
34
+ // needing a server roundtrip per tick. Set up by updateEngineDetail
35
+ // when it sees a running engine; torn down on transition, engine
36
+ // switch, and rig-switch.
37
+ var elapsedTimer = null;
38
+ var elapsedTimerStartedAt = null; // ISO string of the engine the timer is running for
39
+ var ELAPSED_TICK_INTERVAL = 1000;
33
40
 
34
41
  // Tracks engines for which the per-session cost has already been fetched.
35
42
  // Reset when navigating to a new rig. Used to gate the cost fetch so each
@@ -144,16 +151,122 @@
144
151
  }
145
152
  }
146
153
 
147
- function stopSessionStream() {
148
- if (sessionEventSource !== null) {
149
- sessionEventSource.close();
150
- sessionEventSource = null;
154
+ // ── Session transcript polling ─────────────────────────────────────────
155
+
156
+ /**
157
+ * Fetch the transcript snapshot for sessionId and render it into the
158
+ * textarea, preserving scroll pin-to-bottom. Updates the spinner badge
159
+ * based on the returned sessionStatus. Stops polling on terminal.
160
+ */
161
+ function fetchAndRenderTranscript(sessionId) {
162
+ fetch('/api/spider/session-transcript?sessionId=' + encodeURIComponent(sessionId))
163
+ .then(function (r) { return r.json(); })
164
+ .then(function (res) {
165
+ // Bail if polling has been retargeted to a different session.
166
+ if (transcriptPollSessionId !== sessionId) return;
167
+ var ta = document.getElementById('session-log');
168
+ if (ta) {
169
+ // Preserve scroll position if the user has scrolled away from
170
+ // the bottom; otherwise stick to the tail.
171
+ var atBottom =
172
+ ta.scrollTop + ta.clientHeight >= ta.scrollHeight - 4;
173
+ ta.value = renderTranscript(res.messages || []);
174
+ if (atBottom) {
175
+ ta.scrollTop = ta.scrollHeight;
176
+ }
177
+ }
178
+ var status = res && res.sessionStatus;
179
+ var terminal = status && status !== 'running' && status !== 'pending';
180
+ var spinnerEl = document.getElementById('session-log-spinner');
181
+ if (terminal) {
182
+ stopSessionTranscriptPoll();
183
+ if (spinnerEl) spinnerEl.style.display = 'none';
184
+ } else if (spinnerEl) {
185
+ spinnerEl.className = 'badge badge--active';
186
+ spinnerEl.textContent = 'polling\u2026';
187
+ spinnerEl.style.display = '';
188
+ }
189
+ })
190
+ .catch(function () { /* ignore transient errors, keep polling */ });
191
+ }
192
+
193
+ /**
194
+ * Start (or retarget) the transcript polling loop for the given
195
+ * sessionId. Safe to call every rig poll — if the sessionId matches
196
+ * the one we're already polling, this is a no-op. Passing null closes
197
+ * any active poll and hides the session-log UI.
198
+ */
199
+ function startSessionTranscriptPoll(sessionId) {
200
+ if (sessionId === transcriptPollSessionId) return;
201
+
202
+ stopSessionTranscriptPoll();
203
+
204
+ if (!sessionId) {
205
+ var sectionEmpty = document.getElementById('session-log-section');
206
+ if (sectionEmpty) sectionEmpty.style.display = 'none';
207
+ return;
151
208
  }
152
- streamSessionId = null;
153
- streamDone = false;
209
+
210
+ transcriptPollSessionId = sessionId;
211
+
212
+ var sessionLogSection = document.getElementById('session-log-section');
213
+ var sessionLogSpinner = document.getElementById('session-log-spinner');
214
+ var sessionLogTextarea = document.getElementById('session-log');
215
+
216
+ if (sessionLogSection) sessionLogSection.style.display = '';
217
+ if (sessionLogTextarea) sessionLogTextarea.value = '';
218
+
219
+ if (sessionLogSpinner) {
220
+ sessionLogSpinner.className = 'badge badge--active';
221
+ sessionLogSpinner.textContent = 'polling\u2026';
222
+ sessionLogSpinner.style.display = '';
223
+ }
224
+
225
+ // Fire immediately so the user sees transcript content without
226
+ // waiting for the first interval tick.
227
+ fetchAndRenderTranscript(sessionId);
228
+ sessionPollTimer = setInterval(function () {
229
+ fetchAndRenderTranscript(sessionId);
230
+ }, TRANSCRIPT_POLL_INTERVAL);
231
+ }
232
+
233
+ function stopSessionTranscriptPoll() {
234
+ transcriptPollSessionId = null;
154
235
  stopSessionPoll();
155
236
  }
156
237
 
238
+ // ── Elapsed ticker ─────────────────────────────────────────────────────
239
+
240
+ /**
241
+ * Start a 1 s ticker that refreshes the #ed-elapsed text using
242
+ * formatElapsed(startedAt, now). Safe to call repeatedly — if the
243
+ * ticker is already running for the same startedAt, no-op. Passing a
244
+ * different startedAt restarts the ticker so the displayed value
245
+ * comes from the correct engine's start time.
246
+ */
247
+ function startElapsedTimer(startedAt) {
248
+ if (!startedAt) { stopElapsedTimer(); return; }
249
+ if (elapsedTimer !== null && elapsedTimerStartedAt === startedAt) return;
250
+
251
+ stopElapsedTimer();
252
+ elapsedTimerStartedAt = startedAt;
253
+ var tick = function () {
254
+ var el = document.getElementById('ed-elapsed');
255
+ if (!el) return;
256
+ el.innerHTML = esc(formatElapsed(startedAt, new Date().toISOString()));
257
+ };
258
+ tick();
259
+ elapsedTimer = setInterval(tick, ELAPSED_TICK_INTERVAL);
260
+ }
261
+
262
+ function stopElapsedTimer() {
263
+ if (elapsedTimer !== null) {
264
+ clearInterval(elapsedTimer);
265
+ elapsedTimer = null;
266
+ }
267
+ elapsedTimerStartedAt = null;
268
+ }
269
+
157
270
  // ── Rig polling helpers ────────────────────────────────────────────────
158
271
 
159
272
  function isRigInFlight(rig) {
@@ -376,7 +489,8 @@
376
489
  currentRig = rig;
377
490
  selectedEngineId = null;
378
491
 
379
- stopSessionStream();
492
+ stopSessionTranscriptPoll();
493
+ stopElapsedTimer();
380
494
  stopCurrentRigPoll();
381
495
 
382
496
  // Reset per-rig caches so the new rig's engines refetch cost cleanly
@@ -762,15 +876,20 @@
762
876
  setText('ed-started-at', formatDate(engine.startedAt) || '\u2014');
763
877
  setText('ed-completed-at', formatDate(engine.completedAt) || '\u2014');
764
878
 
765
- // Elapsed (only shown for completed-with-times or running-with-start)
879
+ // Elapsed (only shown for completed-with-times or running-with-start).
880
+ // For completed engines we write the final elapsed once and tear down
881
+ // the ticker. For running engines we start a 1 s ticker that keeps
882
+ // #ed-elapsed fresh locally without a server roundtrip per tick.
766
883
  var showElapsed = false;
767
884
  if (engine.status === 'completed' && engine.startedAt && engine.completedAt) {
885
+ stopElapsedTimer();
768
886
  setHtml('ed-elapsed', esc(formatElapsed(engine.startedAt, engine.completedAt)));
769
887
  showElapsed = true;
770
888
  } else if (engine.status === 'running' && engine.startedAt) {
771
- setHtml('ed-elapsed', '<span class="elapsed-running">running\u2026</span>');
889
+ startElapsedTimer(engine.startedAt);
772
890
  showElapsed = true;
773
891
  } else {
892
+ stopElapsedTimer();
774
893
  setHtml('ed-elapsed', '');
775
894
  }
776
895
  setRowDisplay('ed-elapsed-dt', 'ed-elapsed', showElapsed);
@@ -865,6 +984,12 @@
865
984
  }
866
985
  engineStatusByEngineId[engine.id] = engine.status;
867
986
 
987
+ // Transcript polling target is driven off the current engine's
988
+ // sessionId. The dedupe inside startSessionTranscriptPoll makes it
989
+ // safe to call every rig poll — the loop is only torn down and
990
+ // rebuilt when the sessionId actually changes (or becomes null).
991
+ startSessionTranscriptPoll(engine.sessionId || null);
992
+
868
993
  // Pipeline-node selection class (kept in sync without a full re-render).
869
994
  var nodes = document.querySelectorAll('#pipeline .pipeline-node');
870
995
  for (var i = 0; i < nodes.length; i++) {
@@ -907,9 +1032,11 @@
907
1032
 
908
1033
  /**
909
1034
  * Click-path entry: build the skeleton (if needed), populate it via
910
- * updateEngineDetail, ensure the SSE stream points at the engine's
911
- * sessionId, and reveal the panel. Does not do per-poll work — the
912
- * 2 s rig poll calls updateEngineDetail directly via fetchCurrentRigQuiet.
1035
+ * updateEngineDetail, and reveal the panel. updateEngineDetail is
1036
+ * responsible for driving transcript polling and the elapsed ticker,
1037
+ * so no additional lifecycle work is needed here. The 2 s rig poll
1038
+ * calls updateEngineDetail directly via fetchCurrentRigQuiet, which
1039
+ * keeps the transcript loop alive without any click-path glue.
913
1040
  */
914
1041
  function showEngineDetail(engine) {
915
1042
  var engineChanged = selectedEngineId !== engine.id;
@@ -934,177 +1061,13 @@
934
1061
  }
935
1062
 
936
1063
  updateEngineDetail(engine);
937
-
938
- // SSE lifecycle is decoupled from rendering. ensureSessionStream
939
- // reopens only when the tracked sessionId actually changes.
940
- ensureSessionStream(engine.sessionId || null);
941
- }
942
-
943
- // ── SSE session stream lifecycle (decoupled from rendering) ────────────
944
-
945
- /**
946
- * Open the session stream for the given sessionId iff different from
947
- * the one currently tracked. Called from both the click path and the
948
- * poll path's updateEngineDetail; rig-data polls themselves do NOT call
949
- * this — only an engine-selection change or a status-change that
950
- * produces a new sessionId triggers a reopen.
951
- */
952
- function ensureSessionStream(sessionId) {
953
- if (sessionId === streamSessionId) return;
954
- if (!sessionId) {
955
- // Selected engine has no session — close any open stream and hide UI.
956
- stopSessionStream();
957
- var sectionEmpty = document.getElementById('session-log-section');
958
- if (sectionEmpty) sectionEmpty.style.display = 'none';
959
- return;
960
- }
961
- openSessionStream(sessionId);
962
- }
963
-
964
- function openSessionStream(sessionId) {
965
- stopSessionStream();
966
- streamSessionId = sessionId;
967
- streamDone = false;
968
-
969
- var sessionLogSection = document.getElementById('session-log-section');
970
- var sessionLogSpinner = document.getElementById('session-log-spinner');
971
- var sessionLogTextarea = document.getElementById('session-log');
972
-
973
- if (sessionLogSection) sessionLogSection.style.display = '';
974
- if (sessionLogTextarea) sessionLogTextarea.value = '';
975
-
976
- if (sessionLogSpinner) {
977
- sessionLogSpinner.className = 'badge badge--active';
978
- sessionLogSpinner.textContent = 'connecting\u2026';
979
- sessionLogSpinner.style.display = '';
980
- }
981
-
982
- sessionEventSource = new EventSource(
983
- '/api/spider/session-stream?sessionId=' + encodeURIComponent(sessionId)
984
- );
985
-
986
- sessionEventSource.addEventListener('chunk', function (e) {
987
- var chunk;
988
- try { chunk = JSON.parse(e.data); } catch (err) { return; }
989
- var ta = document.getElementById('session-log');
990
- if (!ta) return;
991
- // Capture pin-to-bottom state BEFORE mutating; restore only if pinned.
992
- var atBottom = ta.scrollTop + ta.clientHeight >= ta.scrollHeight - 4;
993
- if (chunk.type === 'text') {
994
- ta.value += chunk.text;
995
- } else if (chunk.type === 'tool_use') {
996
- ta.value += '\n[tool: ' + chunk.tool + ']\n';
997
- } else if (chunk.type === 'tool_result') {
998
- ta.value += '[result: ' + chunk.tool + ']\n';
999
- }
1000
- if (atBottom) {
1001
- ta.scrollTop = ta.scrollHeight;
1002
- }
1003
- var spinner = document.getElementById('session-log-spinner');
1004
- if (spinner) {
1005
- spinner.className = 'badge badge--active';
1006
- spinner.textContent = 'connected';
1007
- }
1008
- });
1009
-
1010
- sessionEventSource.addEventListener('transcript', function (e) {
1011
- var data;
1012
- try { data = JSON.parse(e.data); } catch (err) { return; }
1013
- var ta = document.getElementById('session-log');
1014
- if (!ta) return;
1015
- // Capture pin-to-bottom state BEFORE replacing value; restore only if pinned.
1016
- var atBottom = ta.scrollTop + ta.clientHeight >= ta.scrollHeight - 4;
1017
- ta.value = renderTranscript(data.messages || []);
1018
- if (atBottom) {
1019
- ta.scrollTop = ta.scrollHeight;
1020
- }
1021
- });
1022
-
1023
- sessionEventSource.addEventListener('done', function (e) {
1024
- var data;
1025
- try { data = JSON.parse(e.data); } catch (err) { data = {}; }
1026
- // Mark stream as intentionally done before closing to prevent the
1027
- // onerror handler from showing a spurious "disconnected" badge.
1028
- streamDone = true;
1029
- var pollSessionId = streamSessionId;
1030
- stopSessionStream();
1031
- var spinner = document.getElementById('session-log-spinner');
1032
- if (spinner) {
1033
- spinner.style.display = 'none';
1034
- }
1035
- // If the server has no live in-process stream (e.g. detached sessions
1036
- // where the babysitter is in another process, or after a guild
1037
- // restart), fall back to polling the transcript endpoint until the
1038
- // session reaches a terminal status.
1039
- if (data.noStream && pollSessionId) {
1040
- var fetchTranscript = function () {
1041
- fetch('/api/spider/session-transcript?sessionId=' + encodeURIComponent(pollSessionId))
1042
- .then(function (r) { return r.json(); })
1043
- .then(function (res) {
1044
- var ta = document.getElementById('session-log');
1045
- if (ta) {
1046
- // Preserve scroll position if the user has scrolled away
1047
- // from the bottom; otherwise stick to the tail.
1048
- var atBottom =
1049
- ta.scrollTop + ta.clientHeight >= ta.scrollHeight - 4;
1050
- ta.value = renderTranscript(res.messages || []);
1051
- if (atBottom) {
1052
- ta.scrollTop = ta.scrollHeight;
1053
- }
1054
- }
1055
- var status = res && res.sessionStatus;
1056
- var terminal = status && status !== 'running' && status !== 'pending';
1057
- var spinnerEl = document.getElementById('session-log-spinner');
1058
- if (terminal) {
1059
- stopSessionPoll();
1060
- if (spinnerEl) spinnerEl.style.display = 'none';
1061
- } else if (spinnerEl) {
1062
- spinnerEl.className = 'badge badge--active';
1063
- spinnerEl.textContent = 'polling\u2026';
1064
- spinnerEl.style.display = '';
1065
- }
1066
- })
1067
- .catch(function () { /* ignore transient errors, keep polling */ });
1068
- };
1069
- fetchTranscript();
1070
- stopSessionPoll();
1071
- sessionPollTimer = setInterval(fetchTranscript, 2000);
1072
- }
1073
- });
1074
-
1075
- sessionEventSource.addEventListener('error', function (e) {
1076
- if (streamDone) return;
1077
- var data;
1078
- try { data = JSON.parse(/** @type {MessageEvent} */(e).data || '{}'); } catch (err) { data = {}; }
1079
- var spinner = document.getElementById('session-log-spinner');
1080
- if (spinner) {
1081
- spinner.className = 'badge badge--error';
1082
- spinner.textContent = data.error ? 'error: ' + data.error : 'error';
1083
- spinner.style.display = '';
1084
- }
1085
- stopSessionStream();
1086
- });
1087
-
1088
- sessionEventSource.onerror = function () {
1089
- // Network-level error (browser fires this on connection failure / premature close).
1090
- // Skip if the stream completed normally — the connection close is expected.
1091
- if (streamDone) return;
1092
- if (sessionEventSource) {
1093
- stopSessionStream();
1094
- var spinner = document.getElementById('session-log-spinner');
1095
- if (spinner && spinner.style.display !== 'none') {
1096
- spinner.className = 'badge badge--error';
1097
- spinner.textContent = 'disconnected';
1098
- spinner.style.display = '';
1099
- }
1100
- }
1101
- };
1102
1064
  }
1103
1065
 
1104
1066
  // ── Back to list ───────────────────────────────────────────────────────
1105
1067
 
1106
1068
  function backToList() {
1107
- stopSessionStream();
1069
+ stopSessionTranscriptPoll();
1070
+ stopElapsedTimer();
1108
1071
  stopCurrentRigPoll();
1109
1072
  currentRig = null;
1110
1073
  selectedEngineId = null;