@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.
- package/dist/oculus-routes.d.ts +17 -2
- package/dist/oculus-routes.d.ts.map +1 -1
- package/dist/oculus-routes.js +9 -79
- package/dist/oculus-routes.js.map +1 -1
- package/package.json +9 -9
- package/src/static/spider-ui.test.ts +241 -67
- package/src/static/spider.js +151 -188
package/dist/oculus-routes.d.ts
CHANGED
|
@@ -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
|
|
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"}
|
package/dist/oculus-routes.js
CHANGED
|
@@ -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
|
|
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":"AAAA
|
|
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.
|
|
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.
|
|
26
|
-
"@shardworks/stacks-apparatus": "0.1.
|
|
27
|
-
"@shardworks/
|
|
28
|
-
"@shardworks/
|
|
29
|
-
"@shardworks/
|
|
30
|
-
"@shardworks/loom-apparatus": "0.1.
|
|
31
|
-
"@shardworks/
|
|
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.
|
|
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
|
|
493
|
+
// ── SSE removal regression guard ────────────────────────────────────────
|
|
494
494
|
|
|
495
|
-
describe('spider.js SSE
|
|
496
|
-
|
|
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
|
|
500
|
-
'should track
|
|
535
|
+
/var transcriptPollSessionId\s*=\s*null/,
|
|
536
|
+
'should track transcriptPollSessionId in module scope',
|
|
501
537
|
);
|
|
502
538
|
});
|
|
503
539
|
|
|
504
|
-
it('declares
|
|
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
|
-
|
|
511
|
-
'should
|
|
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
|
|
548
|
+
it('defines startSessionTranscriptPoll that dedupes on sessionId', () => {
|
|
516
549
|
assert.match(
|
|
517
550
|
spiderJs,
|
|
518
|
-
/function
|
|
519
|
-
'should define
|
|
551
|
+
/function startSessionTranscriptPoll\(/,
|
|
552
|
+
'should define startSessionTranscriptPoll',
|
|
520
553
|
);
|
|
521
|
-
const
|
|
522
|
-
/function
|
|
554
|
+
const startBlock = spiderJs.match(
|
|
555
|
+
/function startSessionTranscriptPoll[\s\S]*?(?=\n function )/,
|
|
523
556
|
);
|
|
524
|
-
assert.ok(
|
|
557
|
+
assert.ok(startBlock, 'should find startSessionTranscriptPoll body');
|
|
525
558
|
assert.match(
|
|
526
|
-
|
|
527
|
-
/
|
|
528
|
-
'
|
|
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('
|
|
533
|
-
|
|
534
|
-
|
|
565
|
+
it('defines stopSessionTranscriptPoll that clears transcriptPollSessionId', () => {
|
|
566
|
+
assert.match(
|
|
567
|
+
spiderJs,
|
|
568
|
+
/function stopSessionTranscriptPoll\(/,
|
|
569
|
+
'should define stopSessionTranscriptPoll',
|
|
535
570
|
);
|
|
536
|
-
|
|
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
|
-
|
|
539
|
-
/
|
|
540
|
-
'
|
|
576
|
+
stopBlock[0],
|
|
577
|
+
/transcriptPollSessionId\s*=\s*null/,
|
|
578
|
+
'stopSessionTranscriptPoll should null out transcriptPollSessionId',
|
|
541
579
|
);
|
|
542
|
-
assert.
|
|
543
|
-
|
|
544
|
-
/
|
|
545
|
-
'
|
|
580
|
+
assert.match(
|
|
581
|
+
stopBlock[0],
|
|
582
|
+
/stopSessionPoll\(/,
|
|
583
|
+
'stopSessionTranscriptPoll should clear the underlying timer',
|
|
546
584
|
);
|
|
547
585
|
});
|
|
548
586
|
|
|
549
|
-
it('
|
|
550
|
-
|
|
551
|
-
|
|
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(
|
|
596
|
+
assert.ok(fetchBlock, 'should find fetchAndRenderTranscript body');
|
|
554
597
|
assert.match(
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
'
|
|
598
|
+
fetchBlock[0],
|
|
599
|
+
/\/api\/spider\/session-transcript\?sessionId=/,
|
|
600
|
+
'fetchAndRenderTranscript should hit the transcript endpoint',
|
|
558
601
|
);
|
|
559
602
|
});
|
|
560
|
-
});
|
|
561
603
|
|
|
562
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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(
|
|
620
|
+
assert.ok(fetchBlock, 'should find fetchAndRenderTranscript body');
|
|
570
621
|
assert.match(
|
|
571
|
-
|
|
622
|
+
fetchBlock[0],
|
|
572
623
|
/var atBottom\s*=/,
|
|
573
|
-
'
|
|
624
|
+
'should capture atBottom before mutating the textarea',
|
|
574
625
|
);
|
|
575
626
|
assert.match(
|
|
576
|
-
|
|
627
|
+
fetchBlock[0],
|
|
577
628
|
/if\s*\(atBottom\)/,
|
|
578
|
-
'
|
|
629
|
+
'should restore scroll only when atBottom was true',
|
|
579
630
|
);
|
|
580
631
|
});
|
|
581
632
|
|
|
582
|
-
it('
|
|
583
|
-
const
|
|
584
|
-
/
|
|
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(
|
|
637
|
+
assert.ok(updaterBlock, 'should find updateEngineDetail');
|
|
587
638
|
assert.match(
|
|
588
|
-
|
|
589
|
-
/
|
|
590
|
-
'
|
|
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
|
-
|
|
594
|
-
/
|
|
595
|
-
'
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
607
|
-
/var
|
|
608
|
-
'
|
|
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
|
});
|
package/src/static/spider.js
CHANGED
|
@@ -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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
911
|
-
*
|
|
912
|
-
*
|
|
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
|
-
|
|
1069
|
+
stopSessionTranscriptPoll();
|
|
1070
|
+
stopElapsedTimer();
|
|
1108
1071
|
stopCurrentRigPoll();
|
|
1109
1072
|
currentRig = null;
|
|
1110
1073
|
selectedEngineId = null;
|