@agimon-ai/doompi-runner 0.0.1-alpha.46 → 0.0.1-alpha.49
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/package.json +15 -15
- package/src/web/BashToolMessage.tsx +71 -50
- package/src/web/RunnerLogPanel.tsx +5 -4
- package/src/web/RunnersActivitySection.tsx +94 -60
- package/src/web/runnerTail.ts +82 -0
- package/src/web/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agimon-ai/doompi-runner",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.49",
|
|
4
4
|
"description": "Supervised shell execution, background process control, and run logs for Pi coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -324,12 +324,12 @@
|
|
|
324
324
|
"@xterm/headless": "6.0.0",
|
|
325
325
|
"hono": "4.13.5",
|
|
326
326
|
"typebox": "1.3.19",
|
|
327
|
-
"@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.
|
|
328
|
-
"@agimon-ai/doompi-
|
|
329
|
-
"@agimon-ai/doompi-
|
|
330
|
-
"@agimon-ai/doompi-
|
|
331
|
-
"@agimon-ai/doompi-web-contracts": "0.0.1-alpha.
|
|
332
|
-
"@agimon-ai/doompi-web-security": "0.0.1-alpha.
|
|
327
|
+
"@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.48",
|
|
328
|
+
"@agimon-ai/doompi-ui": "0.0.1-alpha.49",
|
|
329
|
+
"@agimon-ai/doompi-web-components": "0.0.1-alpha.9",
|
|
330
|
+
"@agimon-ai/doompi-telemetry": "0.0.1-alpha.48",
|
|
331
|
+
"@agimon-ai/doompi-web-contracts": "0.0.1-alpha.12",
|
|
332
|
+
"@agimon-ai/doompi-web-security": "0.0.1-alpha.10"
|
|
333
333
|
},
|
|
334
334
|
"devDependencies": {
|
|
335
335
|
"@earendil-works/pi-coding-agent": "0.84.4",
|
|
@@ -357,14 +357,14 @@
|
|
|
357
357
|
}
|
|
358
358
|
},
|
|
359
359
|
"optionalDependencies": {
|
|
360
|
-
"@agimon-ai/doompi-runner-rmux-darwin-arm64": "0.0.1-alpha.
|
|
361
|
-
"@agimon-ai/doompi-runner-rmux-darwin-x64": "0.0.1-alpha.
|
|
362
|
-
"@agimon-ai/doompi-runner-rmux-linux-
|
|
363
|
-
"@agimon-ai/doompi-runner-
|
|
364
|
-
"@agimon-ai/doompi-runner-rtk-darwin-
|
|
365
|
-
"@agimon-ai/doompi-runner-rtk-
|
|
366
|
-
"@agimon-ai/doompi-runner-rtk-linux-
|
|
367
|
-
"@agimon-ai/doompi-runner-
|
|
360
|
+
"@agimon-ai/doompi-runner-rmux-darwin-arm64": "0.0.1-alpha.46",
|
|
361
|
+
"@agimon-ai/doompi-runner-rmux-darwin-x64": "0.0.1-alpha.46",
|
|
362
|
+
"@agimon-ai/doompi-runner-rmux-linux-x64": "0.0.1-alpha.46",
|
|
363
|
+
"@agimon-ai/doompi-runner-rtk-darwin-arm64": "0.0.1-alpha.46",
|
|
364
|
+
"@agimon-ai/doompi-runner-rtk-darwin-x64": "0.0.1-alpha.46",
|
|
365
|
+
"@agimon-ai/doompi-runner-rtk-linux-arm64": "0.0.1-alpha.46",
|
|
366
|
+
"@agimon-ai/doompi-runner-rtk-linux-x64": "0.0.1-alpha.46",
|
|
367
|
+
"@agimon-ai/doompi-runner-rmux-linux-arm64": "0.0.1-alpha.46"
|
|
368
368
|
},
|
|
369
369
|
"engines": {
|
|
370
370
|
"node": ">=22.19.0"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AnsiText,
|
|
2
3
|
Button,
|
|
3
4
|
FileIcon,
|
|
4
5
|
MessageItem,
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
MessageItemHeader,
|
|
7
8
|
MessageItemStatus,
|
|
8
9
|
type StatusTone,
|
|
10
|
+
SyntaxText,
|
|
9
11
|
toolTone,
|
|
10
12
|
} from '@agimon-ai/doompi-web-components';
|
|
11
13
|
import type { ToolMessageRenderProps } from '@agimon-ai/doompi-web-contracts';
|
|
@@ -29,11 +31,13 @@ const STATUS_TONE: Record<BashStatusTone, StatusTone> = {
|
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* The bash tool's timeline item, the web half of renderBashCall and
|
|
32
|
-
* renderBashResult
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* renderBashResult. Collapsed it is a single header row: the tool name, the
|
|
35
|
+
* command shortened to one line, its modifiers, and the outcome badge, so a
|
|
36
|
+
* transcript of many calls stays scannable. Expanding it opens the body: the
|
|
37
|
+
* command exactly as it was run, then the whole tail the frame carries, each
|
|
38
|
+
* in its own scroll box so one chatty command cannot own the viewport. A
|
|
39
|
+
* command promoted to a background runner offers the same stop the activity
|
|
40
|
+
* dock does, for as long as the runners channel reports it running.
|
|
37
41
|
*/
|
|
38
42
|
export function BashToolMessage({
|
|
39
43
|
sessionId,
|
|
@@ -58,30 +62,35 @@ export function BashToolMessage({
|
|
|
58
62
|
runners.store,
|
|
59
63
|
(state) => runnerId !== undefined && runners.select(state, sessionId).stopRequested.includes(runnerId),
|
|
60
64
|
);
|
|
61
|
-
const
|
|
65
|
+
const rawCommand = typeof args.command === 'string' ? args.command : '';
|
|
66
|
+
const command = formatBashCommand(rawCommand);
|
|
62
67
|
const flags = formatBashFlags(args);
|
|
63
|
-
const collapsed = bashResultView({ details: result?.details, output, expanded: false, isPartial: running, isError });
|
|
64
68
|
const stoppable = sessionId !== null && runnerId !== undefined && run !== undefined && run.exit === undefined;
|
|
65
69
|
|
|
66
70
|
return (
|
|
67
|
-
<MessageItem
|
|
68
|
-
tone={toolTone({ running, isError })}
|
|
69
|
-
expandable={collapsed.hidden > 0 || details.logPath !== undefined}
|
|
70
|
-
>
|
|
71
|
+
<MessageItem tone={toolTone({ running, isError })} expandable>
|
|
71
72
|
{({ expanded }) => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
// Nothing is shown until the card is open, so the body always reads the
|
|
74
|
+
// unclipped view; the collapsed state has no lines to bound.
|
|
75
|
+
const view = bashResultView({
|
|
76
|
+
details: result?.details,
|
|
77
|
+
output,
|
|
78
|
+
expanded: true,
|
|
79
|
+
isPartial: running,
|
|
80
|
+
isError,
|
|
81
|
+
});
|
|
75
82
|
return (
|
|
76
83
|
<>
|
|
77
84
|
<MessageItemHeader title="bash">
|
|
78
85
|
<span data-testid="tool-call-bash" className="flex min-w-0 flex-1 items-center gap-2">
|
|
79
86
|
<span className="min-w-0 flex-1 truncate font-mono text-doom-text">{command}</span>
|
|
80
|
-
{flags.length > 0 ?
|
|
87
|
+
{flags.length > 0 ? (
|
|
88
|
+
<span className="hidden shrink-0 text-doom-faint sm:inline">· {flags.join(' · ')}</span>
|
|
89
|
+
) : null}
|
|
81
90
|
</span>
|
|
82
|
-
{/* The
|
|
83
|
-
|
|
84
|
-
|
|
91
|
+
{/* The log and the stop live in the header, not the body: a
|
|
92
|
+
collapsed row still has to reach the whole log and still has
|
|
93
|
+
to be able to stop a run it promoted to the background. */}
|
|
85
94
|
{run !== undefined && sessionId !== null ? (
|
|
86
95
|
<Button
|
|
87
96
|
variant="outline"
|
|
@@ -98,54 +107,66 @@ export function BashToolMessage({
|
|
|
98
107
|
log
|
|
99
108
|
</Button>
|
|
100
109
|
) : null}
|
|
110
|
+
{stoppable ? (
|
|
111
|
+
<Button
|
|
112
|
+
size="xs"
|
|
113
|
+
variant={stopping ? 'outline' : 'danger-outline'}
|
|
114
|
+
disabled={stopping}
|
|
115
|
+
data-testid="tool-result-bash-stop"
|
|
116
|
+
data-stopping={stopping}
|
|
117
|
+
title={
|
|
118
|
+
stopping ? 'stop requested; the runner reports its own exit' : 'ask the runtime to stop this runner'
|
|
119
|
+
}
|
|
120
|
+
onClick={(event) => {
|
|
121
|
+
event.stopPropagation();
|
|
122
|
+
if (sessionId !== null && runnerId !== undefined)
|
|
123
|
+
requestRunnerStop(sendSessionFrame, sessionId, runnerId);
|
|
124
|
+
}}
|
|
125
|
+
className="shrink-0 px-1.5 text-[9px] font-bold"
|
|
126
|
+
>
|
|
127
|
+
{stopping ? 'stopping…' : 'stop'}
|
|
128
|
+
</Button>
|
|
129
|
+
) : null}
|
|
101
130
|
</MessageItemHeader>
|
|
102
|
-
{
|
|
103
|
-
<MessageItemBody data-testid="tool-result-bash" className="flex flex-col gap-
|
|
131
|
+
{expanded ? (
|
|
132
|
+
<MessageItemBody data-testid="tool-result-bash" className="flex flex-col gap-2">
|
|
133
|
+
{/* The header had to shorten the command to one row; this is
|
|
134
|
+
the command as it was actually run, wrapped rather than
|
|
135
|
+
clipped and coloured as the shell it is. */}
|
|
136
|
+
{rawCommand.length > 0 ? (
|
|
137
|
+
<SyntaxText
|
|
138
|
+
data-testid="tool-result-bash-command"
|
|
139
|
+
grammar="shell"
|
|
140
|
+
text={rawCommand}
|
|
141
|
+
className="max-h-40 overflow-auto text-doom-text"
|
|
142
|
+
/>
|
|
143
|
+
) : null}
|
|
144
|
+
{/* The tail arrives with the colours the command wrote: the
|
|
145
|
+
log keeps them, and only the model's copy is flattened. */}
|
|
104
146
|
{view.lines.length > 0 ? (
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
147
|
+
<AnsiText
|
|
148
|
+
data-testid="tool-result-bash-output"
|
|
149
|
+
text={view.lines.join('\n')}
|
|
150
|
+
className="max-h-96 overflow-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] border-doom-border-soft border-t pt-2 font-mono text-doom-dim"
|
|
151
|
+
/>
|
|
110
152
|
) : null}
|
|
111
153
|
{view.status ? (
|
|
112
154
|
<MessageItemStatus
|
|
113
155
|
data-testid="tool-result-bash-status"
|
|
114
156
|
tone={STATUS_TONE[view.status.tone]}
|
|
115
157
|
glyph={view.status.glyph}
|
|
158
|
+
className="min-w-0"
|
|
116
159
|
>
|
|
117
|
-
{view.status.text}
|
|
118
|
-
{view.hidden > 0 ? ` · ${String(view.hidden)} more line(s) above` : ''}
|
|
160
|
+
<span className="break-words">{view.status.text}</span>
|
|
119
161
|
</MessageItemStatus>
|
|
120
162
|
) : null}
|
|
121
|
-
{
|
|
163
|
+
{details.logPath !== undefined ? (
|
|
122
164
|
// The tail is bounded by the runner; the whole log only ever exists on
|
|
123
165
|
// disk, so the item names the file rather than pretending this is all.
|
|
124
|
-
<span data-testid="tool-result-bash-log" className="
|
|
166
|
+
<span data-testid="tool-result-bash-log" className="min-w-0 break-all text-doom-faint">
|
|
125
167
|
full log · <span className="text-doom-dim">{details.logPath}</span>
|
|
126
168
|
</span>
|
|
127
169
|
) : null}
|
|
128
|
-
{stoppable ? (
|
|
129
|
-
<Button
|
|
130
|
-
size="xs"
|
|
131
|
-
variant={stopping ? 'outline' : 'danger-outline'}
|
|
132
|
-
disabled={stopping}
|
|
133
|
-
data-testid="tool-result-bash-stop"
|
|
134
|
-
data-stopping={stopping}
|
|
135
|
-
title={
|
|
136
|
-
stopping
|
|
137
|
-
? 'stop requested; the runner reports its own exit'
|
|
138
|
-
: 'ask the runtime to stop this runner'
|
|
139
|
-
}
|
|
140
|
-
onClick={() => {
|
|
141
|
-
if (sessionId !== null && runnerId !== undefined)
|
|
142
|
-
requestRunnerStop(sendSessionFrame, sessionId, runnerId);
|
|
143
|
-
}}
|
|
144
|
-
className="self-start px-1.5 text-[8px] font-bold"
|
|
145
|
-
>
|
|
146
|
-
{stopping ? 'stopping…' : 'stop'}
|
|
147
|
-
</Button>
|
|
148
|
-
) : null}
|
|
149
170
|
</MessageItemBody>
|
|
150
171
|
) : null}
|
|
151
172
|
</>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Button, Input, SearchIcon, StatusBadge, type StatusTone } from '@agimon-ai/doompi-web-components';
|
|
1
|
+
import { AnsiText, Button, Input, SearchIcon, StatusBadge, type StatusTone } from '@agimon-ai/doompi-web-components';
|
|
2
2
|
import type { TransientTab, WebPluginSlotProps } from '@agimon-ai/doompi-web-contracts';
|
|
3
3
|
import { useStore } from '@tanstack/react-store';
|
|
4
4
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
@@ -249,9 +249,10 @@ export function RunnerLogPanel({ sessionId, runId, sendSessionFrame }: WebPlugin
|
|
|
249
249
|
) : lines.length === 0 ? (
|
|
250
250
|
<p className="text-[10px] text-doom-faint">{filtering ? 'nothing matched' : 'the log is empty'}</p>
|
|
251
251
|
) : (
|
|
252
|
-
<
|
|
253
|
-
{lines.join('\n')}
|
|
254
|
-
|
|
252
|
+
<AnsiText
|
|
253
|
+
text={lines.join('\n')}
|
|
254
|
+
className="whitespace-pre-wrap break-words font-mono text-[10px] leading-[1.6] text-doom-dim"
|
|
255
|
+
/>
|
|
255
256
|
)}
|
|
256
257
|
<div ref={bottom} />
|
|
257
258
|
</div>
|
|
@@ -6,6 +6,7 @@ import type { RunnerRunView } from '../types/webRunners.ts';
|
|
|
6
6
|
import { formatRunnerUptime } from './format.ts';
|
|
7
7
|
import { runnerLogTab } from './RunnerLogPanel.tsx';
|
|
8
8
|
import { requestRunnerStop, runners } from './runnersStore.ts';
|
|
9
|
+
import { useRunnerTail } from './runnerTail.ts';
|
|
9
10
|
|
|
10
11
|
const TICK_MS = 10_000;
|
|
11
12
|
|
|
@@ -39,69 +40,102 @@ export function RunnersActivitySection({ sessionId, sendSessionFrame, openTransi
|
|
|
39
40
|
|
|
40
41
|
return (
|
|
41
42
|
<div data-testid="activity-runner-runs" className="flex flex-col gap-0.5">
|
|
42
|
-
{running.map((run: RunnerRunView) =>
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
{running.map((run: RunnerRunView) => (
|
|
44
|
+
<RunnerRow
|
|
45
|
+
key={run.id}
|
|
46
|
+
run={run}
|
|
47
|
+
now={now}
|
|
48
|
+
stopRequested={session.stopRequested.includes(run.id)}
|
|
49
|
+
sessionId={sessionId}
|
|
50
|
+
sendSessionFrame={sendSessionFrame}
|
|
51
|
+
openTransientTab={openTransientTab}
|
|
52
|
+
/>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One running runner: its name, how long it has been up, a stop control, and
|
|
60
|
+
* one line of what it is doing. Its own component because the tail line is a
|
|
61
|
+
* subscription per row, and a row that scrolls out of the dock closes it.
|
|
62
|
+
*/
|
|
63
|
+
function RunnerRow({
|
|
64
|
+
run,
|
|
65
|
+
now,
|
|
66
|
+
stopRequested,
|
|
67
|
+
sessionId,
|
|
68
|
+
sendSessionFrame,
|
|
69
|
+
openTransientTab,
|
|
70
|
+
}: {
|
|
71
|
+
run: RunnerRunView;
|
|
72
|
+
now: number;
|
|
73
|
+
stopRequested: boolean;
|
|
74
|
+
} & Pick<WebPluginSlotProps, 'sessionId' | 'sendSessionFrame' | 'openTransientTab'>) {
|
|
75
|
+
const tail = useRunnerTail(sessionId, run.id, run.state === 'running');
|
|
76
|
+
return (
|
|
77
|
+
<Button
|
|
78
|
+
variant="ghost"
|
|
79
|
+
size="card"
|
|
80
|
+
data-testid={`activity-runner-${run.id}`}
|
|
81
|
+
data-runner-tone="running"
|
|
82
|
+
title="open this runner's log"
|
|
83
|
+
onClick={() => {
|
|
84
|
+
if (sessionId === null) return;
|
|
85
|
+
openTransientTab(runnerLogTab(run));
|
|
86
|
+
}}
|
|
87
|
+
className="min-w-0 gap-0.5 rounded-[5px] px-1 py-1 hover:bg-doom-panel"
|
|
88
|
+
>
|
|
89
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
90
|
+
<Dot tone="yellow" pulse />
|
|
91
|
+
<span className="min-w-0 flex-1 truncate text-left text-[10px] font-bold text-doom-hi">{run.name}</span>
|
|
92
|
+
<span className="shrink-0 text-[9px] text-doom-faint">
|
|
93
|
+
{formatRunnerUptime(run.startedAt, now)}
|
|
94
|
+
{run.interactive ? ' · tty' : ''}
|
|
95
|
+
</span>
|
|
96
|
+
{sessionId !== null ? (
|
|
45
97
|
<Button
|
|
46
|
-
|
|
47
|
-
variant=
|
|
48
|
-
size="
|
|
49
|
-
data-testid={`activity-runner-${run.id}`}
|
|
50
|
-
data-
|
|
51
|
-
title=
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}}
|
|
56
|
-
className="min-w-0 gap-0.5 rounded-[5px] px-1 py-1 hover:bg-doom-panel"
|
|
98
|
+
asChild
|
|
99
|
+
variant={stopRequested ? 'outline' : 'danger-outline'}
|
|
100
|
+
size="xs"
|
|
101
|
+
data-testid={`activity-runner-stop-${run.id}`}
|
|
102
|
+
data-stopping={stopRequested}
|
|
103
|
+
title={
|
|
104
|
+
stopRequested ? 'stop requested; the runner reports its own exit' : 'ask the runtime to stop this runner'
|
|
105
|
+
}
|
|
106
|
+
className="px-1.5 text-[8px] font-bold"
|
|
57
107
|
>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
className="px-1.5 text-[8px] font-bold"
|
|
78
|
-
>
|
|
79
|
-
{/* The row is already a button, so the stop control lends it
|
|
80
|
-
the primitive's styling rather than nesting a second one. */}
|
|
81
|
-
<span
|
|
82
|
-
role="button"
|
|
83
|
-
tabIndex={stopRequested ? -1 : 0}
|
|
84
|
-
aria-disabled={stopRequested}
|
|
85
|
-
onClick={(event) => {
|
|
86
|
-
event.stopPropagation();
|
|
87
|
-
if (!stopRequested) requestRunnerStop(sendSessionFrame, sessionId, run.id);
|
|
88
|
-
}}
|
|
89
|
-
onKeyDown={(event) => {
|
|
90
|
-
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
91
|
-
event.preventDefault();
|
|
92
|
-
event.stopPropagation();
|
|
93
|
-
if (!stopRequested) requestRunnerStop(sendSessionFrame, sessionId, run.id);
|
|
94
|
-
}}
|
|
95
|
-
>
|
|
96
|
-
{stopRequested ? 'stopping…' : 'stop'}
|
|
97
|
-
</span>
|
|
98
|
-
</Button>
|
|
99
|
-
) : null}
|
|
108
|
+
{/* The row is already a button, so the stop control lends it
|
|
109
|
+
the primitive's styling rather than nesting a second one. */}
|
|
110
|
+
<span
|
|
111
|
+
role="button"
|
|
112
|
+
tabIndex={stopRequested ? -1 : 0}
|
|
113
|
+
aria-disabled={stopRequested}
|
|
114
|
+
onClick={(event) => {
|
|
115
|
+
event.stopPropagation();
|
|
116
|
+
if (!stopRequested) requestRunnerStop(sendSessionFrame, sessionId, run.id);
|
|
117
|
+
}}
|
|
118
|
+
onKeyDown={(event) => {
|
|
119
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
event.stopPropagation();
|
|
122
|
+
if (!stopRequested) requestRunnerStop(sendSessionFrame, sessionId, run.id);
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{stopRequested ? 'stopping…' : 'stop'}
|
|
100
126
|
</span>
|
|
101
|
-
<span className="truncate pl-3 text-left text-[9px] text-doom-faint">{run.command}</span>
|
|
102
127
|
</Button>
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
|
|
128
|
+
) : null}
|
|
129
|
+
</span>
|
|
130
|
+
{/* What it is doing, falling back to what it was asked to do until the
|
|
131
|
+
first line of output arrives. */}
|
|
132
|
+
<span
|
|
133
|
+
data-testid={`activity-runner-detail-${run.id}`}
|
|
134
|
+
data-detail={tail === undefined ? 'command' : 'tail'}
|
|
135
|
+
className="truncate pl-3 text-left text-[9px] text-doom-faint"
|
|
136
|
+
>
|
|
137
|
+
{tail ?? run.command}
|
|
138
|
+
</span>
|
|
139
|
+
</Button>
|
|
106
140
|
);
|
|
107
141
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { ansiSpans } from '@agimon-ai/doompi-web-components';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { fetchRunnerLog, followRunnerLog, type RunnerLogFollow } from './logApi.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The newest line a running runner has written, for a row that has one line to
|
|
7
|
+
* spend.
|
|
8
|
+
*
|
|
9
|
+
* The dock answers "what is happening", and a command that has not changed
|
|
10
|
+
* since it started answers "what was asked". The last line of the log is the
|
|
11
|
+
* closest thing to progress the cockpit can show without opening the log, so a
|
|
12
|
+
* row that is still running trades its command for it.
|
|
13
|
+
*
|
|
14
|
+
* The line comes from the same two calls the log panel makes: read the tail,
|
|
15
|
+
* then follow the stream from exactly where that read ended. Nothing new goes
|
|
16
|
+
* on the wire, and a runner nobody is looking at is not followed, because the
|
|
17
|
+
* stream lives and dies with the row.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Enough lines that a trailing blank, or a final progress redraw, is not the whole answer. */
|
|
21
|
+
const TAIL_REQUEST_LINES = 5;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The last line worth showing, as plain text.
|
|
25
|
+
*
|
|
26
|
+
* Blank lines are skipped, because a log that ends with a newline would
|
|
27
|
+
* otherwise report nothing at all. Colour is dropped rather than rendered: the
|
|
28
|
+
* row is nine pixels of faint text beside a name and a stop control, and a
|
|
29
|
+
* green tick from someone else's palette in the middle of it reads as damage.
|
|
30
|
+
*/
|
|
31
|
+
export function plainTailLine(lines: readonly string[]): string | undefined {
|
|
32
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
33
|
+
const text = ansiSpans(lines[index] ?? '')
|
|
34
|
+
.map((span) => span.text)
|
|
35
|
+
.join('')
|
|
36
|
+
.trim();
|
|
37
|
+
if (text.length > 0) return text;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The newest log line of a running runner, or undefined for a runner that is
|
|
44
|
+
* finished, unreachable, or has not written anything yet. The caller decides
|
|
45
|
+
* what to show instead; this hook never invents a placeholder.
|
|
46
|
+
*/
|
|
47
|
+
export function useRunnerTail(sessionId: string | null, runId: string, running: boolean): string | undefined {
|
|
48
|
+
// Kept with the run it belongs to, so a row that is reused for another
|
|
49
|
+
// runner shows nothing rather than the previous runner's output.
|
|
50
|
+
const [tail, setTail] = useState<{ runId: string; text: string } | undefined>(undefined);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!running || sessionId === null) return;
|
|
54
|
+
let live = true;
|
|
55
|
+
let follow: RunnerLogFollow | undefined;
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
void fetchRunnerLog(sessionId, runId, { lines: TAIL_REQUEST_LINES }, controller.signal).then((result) => {
|
|
58
|
+
// A log the hub cannot serve leaves the row on its command, which is
|
|
59
|
+
// still true; the log tab is where an unreachable runner is diagnosed.
|
|
60
|
+
if (!live || 'error' in result) return;
|
|
61
|
+
const initial = plainTailLine(result.slice.text.split('\n'));
|
|
62
|
+
if (initial !== undefined) setTail({ runId, text: initial });
|
|
63
|
+
if (!result.slice.running) return;
|
|
64
|
+
follow = followRunnerLog(sessionId, runId, result.slice.fileSize, {
|
|
65
|
+
onEvent: (event) => {
|
|
66
|
+
const next = plainTailLine(event.lines);
|
|
67
|
+
if (live && next !== undefined) setTail({ runId, text: next });
|
|
68
|
+
},
|
|
69
|
+
// A dropped stream freezes the line at the last one that arrived. The
|
|
70
|
+
// row is a glance, and reconnecting it would cost more than it says.
|
|
71
|
+
onError: () => undefined,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
return () => {
|
|
75
|
+
live = false;
|
|
76
|
+
controller.abort();
|
|
77
|
+
follow?.close();
|
|
78
|
+
};
|
|
79
|
+
}, [sessionId, runId, running]);
|
|
80
|
+
|
|
81
|
+
return running && tail?.runId === runId ? tail.text : undefined;
|
|
82
|
+
}
|
package/src/web/tsconfig.json
CHANGED