@spunto/design-system 0.27.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -1
- package/package.json +6 -1
- package/src/components/sheet.tsx +7 -1
- package/src/components/tasks/agent-markdown.tsx +322 -0
- package/src/components/tasks/attachments.ts +152 -0
- package/src/components/tasks/command-log.tsx +102 -0
- package/src/components/tasks/composer.tsx +252 -0
- package/src/components/tasks/conversation-list.tsx +238 -0
- package/src/components/tasks/diff-panel.tsx +534 -0
- package/src/components/tasks/event-stream.tsx +860 -0
- package/src/components/tasks/index.ts +111 -0
- package/src/components/tasks/machine.tsx +455 -0
- package/src/components/tasks/new-task-dialog.tsx +301 -0
- package/src/components/tasks/pull-request.tsx +179 -0
- package/src/components/tasks/session-usage.tsx +165 -0
- package/src/components/tasks/task-attachments.tsx +475 -0
- package/src/components/tasks/task-cockpit-mobile.tsx +272 -0
- package/src/components/tasks/task-cockpit.tsx +225 -0
- package/src/components/tasks/task-details.tsx +239 -0
- package/src/components/tasks/task-list.tsx +272 -0
- package/src/components/tasks/task-state.tsx +180 -0
- package/src/components/tasks/tasks.test.tsx +205 -0
- package/src/components/tasks/tool-stack.tsx +692 -0
- package/src/components/tasks/types.ts +220 -0
- package/src/components/tasks/worker-link.tsx +126 -0
- package/src/components/workers/index.ts +5 -2
- package/src/components/workers/types.ts +22 -0
- package/src/components/workers/worker-cockpit.tsx +17 -2
- package/src/components/workers/worker-status-history.test.ts +85 -0
- package/src/components/workers/worker-status-history.tsx +166 -0
- package/src/index.ts +1 -1
- package/src/utils.ts +23 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Structural shapes for everything task-related — delegated work (docs/tasks.md), its session
|
|
2
|
+
// (RFC 0020) and its files (RFC 0022).
|
|
3
|
+
//
|
|
4
|
+
// Same rule as `../workers/types.ts`: the package never imports an app's OpenAPI types. These
|
|
5
|
+
// describe the minimum the components read. Where the dashboard's API is the only producer today
|
|
6
|
+
// the shape follows it closely, but every component tolerates a value it doesn't know — an
|
|
7
|
+
// unknown state falls back to a neutral style, an unknown event type to a boundary line — rather
|
|
8
|
+
// than crashing on a payload a newer API sends.
|
|
9
|
+
|
|
10
|
+
/** The five states a task goes through. Derived at read time by the API, never polled into place. */
|
|
11
|
+
export type TaskState = "queued" | "running" | "in-review" | "done" | "failed"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What the platform is doing to a task *right now*, on a human's behalf.
|
|
15
|
+
*
|
|
16
|
+
* Accepting, dropping and answering are `202`s that finish in a durable job minutes later, so a
|
|
17
|
+
* task can be `in-review` and *being accepted* at the same time — and the second fact is the one
|
|
18
|
+
* the reader who just clicked is looking for.
|
|
19
|
+
*/
|
|
20
|
+
export type TaskPendingAction = "accepting" | "dropping" | "replying"
|
|
21
|
+
|
|
22
|
+
/** A task as the list rows, the cockpit and its sidebar draw it. */
|
|
23
|
+
export interface TaskItem {
|
|
24
|
+
id: string
|
|
25
|
+
title: string
|
|
26
|
+
prompt?: string | null
|
|
27
|
+
state: TaskState
|
|
28
|
+
branch?: string | null
|
|
29
|
+
baseBranch?: string | null
|
|
30
|
+
workerId?: string | null
|
|
31
|
+
/** Where the work landed — a PR, an MR, a preview deployment. Not forge-specific. */
|
|
32
|
+
outcomeUrl?: string | null
|
|
33
|
+
outcomeLabel?: string | null
|
|
34
|
+
/** Legacy: what a removed status command once reported. Drawn as JSON when present. */
|
|
35
|
+
meta?: Record<string, unknown> | null
|
|
36
|
+
error?: string | null
|
|
37
|
+
createdAt: string
|
|
38
|
+
/** When the task last moved — a turn, a state change. What lists sort and date rows on. */
|
|
39
|
+
lastActivityAt?: string | null
|
|
40
|
+
startedAt?: string | null
|
|
41
|
+
completedAt?: string | null
|
|
42
|
+
/** Whether a follow-up can resume the session — a captured `session.started` gave an id. */
|
|
43
|
+
canResume?: boolean
|
|
44
|
+
pendingAction?: TaskPendingAction | null
|
|
45
|
+
/** When that was asked for. What the spinner counts from — across reloads and readers. */
|
|
46
|
+
pendingSince?: string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A row of the org-wide conversation list: a task plus the project it belongs to. */
|
|
50
|
+
export interface TaskConversation extends TaskItem {
|
|
51
|
+
projectId: string
|
|
52
|
+
projectName?: string | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One normalized event of a session (`task_events`). `payload` is read laxly by type. */
|
|
56
|
+
export interface TaskEvent {
|
|
57
|
+
seq: number
|
|
58
|
+
ts: string
|
|
59
|
+
/** `message` | `thinking` | `plan` | `tool.call` | `tool.result` | `session.started` | `session.ended` | `usage` | `session.title` | `raw` … */
|
|
60
|
+
type: string
|
|
61
|
+
payload: Record<string, unknown>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A command the platform ran for a task (branch setup, the session, accept…), with its captured tail. */
|
|
65
|
+
export interface TaskCommand {
|
|
66
|
+
id: string
|
|
67
|
+
command: string
|
|
68
|
+
cwd?: string | null
|
|
69
|
+
mode?: "sync" | "background" | string
|
|
70
|
+
/** `running` | `succeeded` | `failed` | `timeout` | `canceled` | `lost`. */
|
|
71
|
+
status: string
|
|
72
|
+
exitCode?: number | null
|
|
73
|
+
stdout?: string | null
|
|
74
|
+
stderr?: string | null
|
|
75
|
+
truncated?: boolean
|
|
76
|
+
createdAt: string
|
|
77
|
+
finishedAt?: string | null
|
|
78
|
+
label?: string | null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What a session has consumed — task-wide, derived by the API from the whole event log. */
|
|
82
|
+
export interface TaskSessionUsageData {
|
|
83
|
+
model?: string | null
|
|
84
|
+
contextTokens?: number | null
|
|
85
|
+
contextWindow?: number | null
|
|
86
|
+
inputTokens?: number | null
|
|
87
|
+
outputTokens?: number | null
|
|
88
|
+
cacheReadTokens?: number | null
|
|
89
|
+
cacheCreationTokens?: number | null
|
|
90
|
+
costUsd?: number | null
|
|
91
|
+
turns?: number | null
|
|
92
|
+
durationMs?: number | null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A file already in the conversation, as an event's payload carries it. */
|
|
96
|
+
export interface TaskFileRef {
|
|
97
|
+
id: string
|
|
98
|
+
filename: string | null
|
|
99
|
+
mediaType: string
|
|
100
|
+
bytes: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Where one of a task's stored files is served from. Built by the app, which knows the route. */
|
|
104
|
+
export type AttachmentUrl = (attachmentId: string) => string
|
|
105
|
+
|
|
106
|
+
/** A file prepared in the browser for a turn — see `prepareFile`. */
|
|
107
|
+
export interface DraftFile {
|
|
108
|
+
/** Local only — React keys and the remove button. */
|
|
109
|
+
key: string
|
|
110
|
+
name: string
|
|
111
|
+
mediaType: string
|
|
112
|
+
/** Base64, no data-url prefix. */
|
|
113
|
+
data: string
|
|
114
|
+
bytes: number
|
|
115
|
+
/** Object URL for the thumbnail — images only. Revoked by whoever drops the draft. */
|
|
116
|
+
previewUrl: string | null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A message that has been sent but has not come back yet.
|
|
121
|
+
*
|
|
122
|
+
* `sinceSeq` is the timeline's high-water mark at the moment it was sent: matching on text alone
|
|
123
|
+
* would make a repeated message ("continue", twice) match the *previous* one's event.
|
|
124
|
+
*/
|
|
125
|
+
export interface PendingMessage {
|
|
126
|
+
text: string
|
|
127
|
+
sinceSeq: number
|
|
128
|
+
/** Object URLs of the images going with it, drawn until the stored ones come back. */
|
|
129
|
+
previews?: string[]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Diff ─────────────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export interface TaskDiffFile {
|
|
135
|
+
path: string
|
|
136
|
+
oldPath?: string | null
|
|
137
|
+
/** `added` | `modified` | `deleted` | `renamed` | `copied` | `type-changed` | `untracked`. */
|
|
138
|
+
status: string
|
|
139
|
+
additions: number | null
|
|
140
|
+
deletions: number | null
|
|
141
|
+
binary?: boolean
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface TaskRepoDiff {
|
|
145
|
+
cwd?: string
|
|
146
|
+
name: string
|
|
147
|
+
base?: string | null
|
|
148
|
+
branch?: string | null
|
|
149
|
+
files: TaskDiffFile[]
|
|
150
|
+
additions: number
|
|
151
|
+
deletions: number
|
|
152
|
+
error?: string | null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** What a branch changes, read with git in the task's own machine. */
|
|
156
|
+
export interface TaskDiff {
|
|
157
|
+
available: boolean
|
|
158
|
+
/** Why it could not be read: `no-machine` | `machine-stopped` | `node-unreachable` | `unsupported`. */
|
|
159
|
+
reason?: string | null
|
|
160
|
+
branch: string
|
|
161
|
+
baseBranch?: string | null
|
|
162
|
+
repos: TaskRepoDiff[]
|
|
163
|
+
additions: number
|
|
164
|
+
deletions: number
|
|
165
|
+
files: number
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface TaskPatchLine {
|
|
169
|
+
kind: "context" | "add" | "del"
|
|
170
|
+
old: number | null
|
|
171
|
+
new: number | null
|
|
172
|
+
text: string
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface TaskPatchHunk {
|
|
176
|
+
header: string
|
|
177
|
+
context?: string | null
|
|
178
|
+
lines: TaskPatchLine[]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface TaskFilePatch {
|
|
182
|
+
binary?: boolean
|
|
183
|
+
truncated?: boolean
|
|
184
|
+
hunks: TaskPatchHunk[]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Pull request ─────────────────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/** A proposal at the forge for the task's branch — reported, never observed. */
|
|
190
|
+
export interface TaskPullRequestData {
|
|
191
|
+
/** Short reference, e.g. `#289` or `!42`. */
|
|
192
|
+
label: string
|
|
193
|
+
url: string
|
|
194
|
+
title: string
|
|
195
|
+
state: "draft" | "open" | "merged" | "closed"
|
|
196
|
+
checks?: "pending" | "passing" | "failing" | null
|
|
197
|
+
review?: "approved" | "changes-requested" | "pending" | null
|
|
198
|
+
comments?: number | null
|
|
199
|
+
updatedAt?: string | null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface TaskPullRequests {
|
|
203
|
+
/** `false` → nothing connected can answer; the section is absent, not empty. */
|
|
204
|
+
supported: boolean
|
|
205
|
+
repos: { repo: string; pullRequest: TaskPullRequestData | null; error?: string | null }[]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Machine ──────────────────────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/** The node a task's worker lives on. */
|
|
211
|
+
export interface TaskMachineNode {
|
|
212
|
+
name: string
|
|
213
|
+
online?: boolean
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A port detected listening inside the task's container. */
|
|
217
|
+
export interface TaskMachinePort {
|
|
218
|
+
port: number
|
|
219
|
+
process?: string | null
|
|
220
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
// The pairing a project page is made of, drawn from both ends.
|
|
4
|
+
//
|
|
5
|
+
// A task has a machine and a machine is sometimes held by a task — the same fact, and neither list
|
|
6
|
+
// said it. Each row now names its counterpart and links to it, so the two tabs of a project page
|
|
7
|
+
// (workspaces, tasks) are one story told twice rather than two lists to correlate by hand. Both
|
|
8
|
+
// directions are resolved from the two lists the page already holds: no extra round trip.
|
|
9
|
+
|
|
10
|
+
import { ServerIcon } from "lucide-react"
|
|
11
|
+
|
|
12
|
+
import { cn } from "../../utils"
|
|
13
|
+
import type { WorkerCardWorker } from "../workers/types"
|
|
14
|
+
import type { LinkRender } from "../workers/worker-card"
|
|
15
|
+
import { WorkerStatusDot, resolveWorkerStatus } from "../workers/worker-status"
|
|
16
|
+
import { isLiveTaskState, taskStateStyle } from "./task-state"
|
|
17
|
+
import { workerLabel } from "./machine"
|
|
18
|
+
import type { TaskItem } from "./types"
|
|
19
|
+
|
|
20
|
+
const defaultLink: LinkRender = ({ href, className, children }) => (
|
|
21
|
+
<a href={href} className={className}>
|
|
22
|
+
{children}
|
|
23
|
+
</a>
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
/** The task to show for a worker, and whether it still holds the machine. */
|
|
27
|
+
export type WorkerTask<T extends TaskItem = TaskItem> = { task: T; live: boolean }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Worker id → the task to name in its row.
|
|
31
|
+
*
|
|
32
|
+
* A pooled worker is reused across tasks, so "its task" is only unambiguous while one is live: a
|
|
33
|
+
* task holds its machine until it reaches a terminal state, so a live task wins. Once none is,
|
|
34
|
+
* the most recent one that ran there is still worth showing — as history — because it is what the
|
|
35
|
+
* branch checked out in that workspace belongs to.
|
|
36
|
+
*/
|
|
37
|
+
export function indexTasksByWorker<T extends TaskItem>(tasks: T[]): Map<string, WorkerTask<T>> {
|
|
38
|
+
const byWorker = new Map<string, WorkerTask<T>>()
|
|
39
|
+
const at = (t: TaskItem) => new Date(t.lastActivityAt ?? t.createdAt).getTime()
|
|
40
|
+
// Most recently active first, so the first task seen for a worker is already the one to keep
|
|
41
|
+
// unless a live one turns up behind it.
|
|
42
|
+
const ordered = [...tasks].sort((a, b) => at(b) - at(a))
|
|
43
|
+
for (const task of ordered) {
|
|
44
|
+
if (!task.workerId) continue
|
|
45
|
+
const live = isLiveTaskState(task.state)
|
|
46
|
+
const current = byWorker.get(task.workerId)
|
|
47
|
+
if (!current || (live && !current.live)) byWorker.set(task.workerId, { task, live })
|
|
48
|
+
}
|
|
49
|
+
return byWorker
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What a workspace row says about the task on it: state as a dot, title as the link. Muted when
|
|
54
|
+
* the task is over — the machine is free, and the row should not read as busy.
|
|
55
|
+
*/
|
|
56
|
+
export function WorkerTaskChip({
|
|
57
|
+
entry,
|
|
58
|
+
href,
|
|
59
|
+
render,
|
|
60
|
+
className,
|
|
61
|
+
}: {
|
|
62
|
+
entry: WorkerTask
|
|
63
|
+
href: string
|
|
64
|
+
render?: { link?: LinkRender }
|
|
65
|
+
className?: string
|
|
66
|
+
}) {
|
|
67
|
+
const { task, live } = entry
|
|
68
|
+
const Link = render?.link ?? defaultLink
|
|
69
|
+
const label = taskStateStyle(task.state).label
|
|
70
|
+
return (
|
|
71
|
+
<span title={live ? `${label} — ${task.title}` : `Last task that ran here (${label}) — ${task.title}`} className="inline-flex min-w-0">
|
|
72
|
+
<Link
|
|
73
|
+
href={href}
|
|
74
|
+
className={cn(
|
|
75
|
+
"inline-flex min-w-0 items-center gap-1.5 text-xs underline-offset-2 transition-colors hover:text-foreground hover:underline",
|
|
76
|
+
live ? "text-foreground/80" : "text-muted-foreground/50",
|
|
77
|
+
className,
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<span className={cn("h-1.5 w-1.5 shrink-0 rounded-full", taskStateStyle(task.state).dotClass, !live && "opacity-50")} />
|
|
81
|
+
<span className="truncate">{task.title}</span>
|
|
82
|
+
</Link>
|
|
83
|
+
</span>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What a task row says about its machine: the workspace's name and its live status, so a task
|
|
89
|
+
* sitting in review on a stopped worker says so without being opened.
|
|
90
|
+
*
|
|
91
|
+
* `worker` is undefined when the row is gone from the project's list — a deleted machine leaves
|
|
92
|
+
* `workerId` behind on purpose (the record of the work outlives it), so the link still points
|
|
93
|
+
* somewhere and just stops claiming a status it can't observe.
|
|
94
|
+
*/
|
|
95
|
+
export function TaskWorkerChip({
|
|
96
|
+
worker,
|
|
97
|
+
href,
|
|
98
|
+
render,
|
|
99
|
+
className,
|
|
100
|
+
}: {
|
|
101
|
+
worker?: WorkerCardWorker | null
|
|
102
|
+
href?: string
|
|
103
|
+
render?: { link?: LinkRender }
|
|
104
|
+
className?: string
|
|
105
|
+
}) {
|
|
106
|
+
const Link = render?.link ?? defaultLink
|
|
107
|
+
const status = worker ? resolveWorkerStatus(worker) : null
|
|
108
|
+
const body = (
|
|
109
|
+
<>
|
|
110
|
+
{status ? <WorkerStatusDot status={status} /> : <ServerIcon className="h-3 w-3 shrink-0" />}
|
|
111
|
+
<span className="max-w-[160px] truncate">{worker ? workerLabel(worker) : "workspace"}</span>
|
|
112
|
+
</>
|
|
113
|
+
)
|
|
114
|
+
const cls = cn("flex min-w-0 items-center gap-1.5 transition-colors hover:text-foreground", className)
|
|
115
|
+
return (
|
|
116
|
+
<span title={status ? `Workspace — ${status.label}` : "The workspace this task ran on"} className="inline-flex min-w-0">
|
|
117
|
+
{href ? (
|
|
118
|
+
<Link href={href} className={cls}>
|
|
119
|
+
{body}
|
|
120
|
+
</Link>
|
|
121
|
+
) : (
|
|
122
|
+
<span className={cls}>{body}</span>
|
|
123
|
+
)}
|
|
124
|
+
</span>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
@@ -27,6 +27,9 @@ export type { SetupProgressProps, StepState } from "./setup-progress"
|
|
|
27
27
|
export { WorkerSetupTimeline } from "./worker-setup-timeline"
|
|
28
28
|
export { SetupWaterfall } from "./setup-waterfall"
|
|
29
29
|
|
|
30
|
+
export { WorkerStatusHistory, statusHistorySpans } from "./worker-status-history"
|
|
31
|
+
export type { WorkerStatusHistoryProps, WorkerStatusSpan } from "./worker-status-history"
|
|
32
|
+
|
|
30
33
|
export { ResourceBars } from "./resource-bars"
|
|
31
34
|
export type { ResourceBarsProps, WorkerStats } from "./resource-bars"
|
|
32
35
|
|
|
@@ -40,8 +43,8 @@ export type { TagPalette } from "./tag-color"
|
|
|
40
43
|
|
|
41
44
|
export { CreatorAvatar } from "./creator-avatar"
|
|
42
45
|
|
|
43
|
-
export type { WorkerCardWorker, WorkerCockpitWorker, WorkerCreator, WorkerSetupStatus } from "./types"
|
|
46
|
+
export type { WorkerCardWorker, WorkerCockpitWorker, WorkerCreator, WorkerSetupStatus, WorkerStatusEvent } from "./types"
|
|
44
47
|
|
|
45
48
|
// Re-exported here too so a consumer of the worker surface doesn't have to
|
|
46
49
|
// import the root entry just to format the timestamps it feeds these components.
|
|
47
|
-
export { cn, formatDuration, formatRelativeTime } from "../../utils"
|
|
50
|
+
export { cn, formatDuration, formatElapsed, formatRelativeTime } from "../../utils"
|
|
@@ -68,3 +68,25 @@ export interface WorkerCockpitWorker extends WorkerCardWorker {
|
|
|
68
68
|
branch?: string | null
|
|
69
69
|
containerId?: string | null
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* One row of a worker's status history — a transition, as the control plane journaled it.
|
|
74
|
+
*
|
|
75
|
+
* Mirrors a `worker_status_events` row of the dashboard (`from`, `to`, `source`, `meta`, `at`),
|
|
76
|
+
* laxly: `to` and `at` are the only fields the history can't draw without. `source` is a free
|
|
77
|
+
* string on purpose — the package phrases the ones it knows and shows the others as they come.
|
|
78
|
+
*/
|
|
79
|
+
export interface WorkerStatusEvent {
|
|
80
|
+
/** Order within the worker. Falls back to `at` when absent. */
|
|
81
|
+
seq?: number | null
|
|
82
|
+
/** ISO 8601 — when the worker *entered* `to`. */
|
|
83
|
+
at: string
|
|
84
|
+
/** `null` for the very first transition (the worker's creation). */
|
|
85
|
+
from?: string | null
|
|
86
|
+
/** Lifecycle state entered — same vocabulary as `WorkerCardWorker.state`. */
|
|
87
|
+
to: string
|
|
88
|
+
/** Who wrote it: `spawn` | `agent-event` | `reconciliation` | `command` | `setup-monitor` | `boot-recovery` | `node-disconnect`. */
|
|
89
|
+
source?: string | null
|
|
90
|
+
/** The why, same shape as `WorkerCardWorker.statusMeta`. */
|
|
91
|
+
meta?: WorkerCardWorker["statusMeta"]
|
|
92
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
// The full worker page, as one component. Top bar, logs/terminal tabs, and a
|
|
4
|
-
// control panel (setup, resources, repos, ports, tags, actions, info) that
|
|
4
|
+
// control panel (setup, resources, repos, ports, tags, actions, history, info) that
|
|
5
5
|
// lives in a sidebar on desktop and a bottom sheet on mobile — same content,
|
|
6
6
|
// because a phone doesn't need less of it, just a different place to put it.
|
|
7
7
|
//
|
|
@@ -44,10 +44,11 @@ import { type GitRepoStatus, repoName } from "./git-branch-chips"
|
|
|
44
44
|
import { ResourceBars, type WorkerStats } from "./resource-bars"
|
|
45
45
|
import { SetupWaterfall } from "./setup-waterfall"
|
|
46
46
|
import { TagChips } from "./tag-chips"
|
|
47
|
-
import type { WorkerCockpitWorker, WorkerCreator } from "./types"
|
|
47
|
+
import type { WorkerCockpitWorker, WorkerCreator, WorkerStatusEvent } from "./types"
|
|
48
48
|
import { type LinkRender } from "./worker-card"
|
|
49
49
|
import { WorkerSetupTimeline } from "./worker-setup-timeline"
|
|
50
50
|
import { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus } from "./worker-status"
|
|
51
|
+
import { WorkerStatusHistory } from "./worker-status-history"
|
|
51
52
|
|
|
52
53
|
type CockpitTab = "logs" | "terminal"
|
|
53
54
|
|
|
@@ -104,6 +105,12 @@ export interface WorkerCockpitProps {
|
|
|
104
105
|
/** Fetches the one-shot SSH command; the cockpit copies it to the clipboard and owns the button's own idle/loading/copied state. */
|
|
105
106
|
onCopySsh?: () => Promise<string>
|
|
106
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The worker's status transitions, as journaled by the control plane. Omitted or empty → no
|
|
110
|
+
* History section. The current stay is measured up to `now` at render time.
|
|
111
|
+
*/
|
|
112
|
+
statusHistory?: WorkerStatusEvent[]
|
|
113
|
+
|
|
107
114
|
/** A failed mutation, typically. */
|
|
108
115
|
error?: string | null
|
|
109
116
|
onDismissError?: () => void
|
|
@@ -180,6 +187,7 @@ export function WorkerCockpit({
|
|
|
180
187
|
onDelete,
|
|
181
188
|
deleting = false,
|
|
182
189
|
onCopySsh,
|
|
190
|
+
statusHistory,
|
|
183
191
|
error,
|
|
184
192
|
onDismissError,
|
|
185
193
|
activeTab,
|
|
@@ -394,6 +402,13 @@ export function WorkerCockpit({
|
|
|
394
402
|
</div>
|
|
395
403
|
)}
|
|
396
404
|
|
|
405
|
+
{!!statusHistory?.length && (
|
|
406
|
+
<div>
|
|
407
|
+
<SectionLabel>History</SectionLabel>
|
|
408
|
+
<WorkerStatusHistory events={statusHistory} />
|
|
409
|
+
</div>
|
|
410
|
+
)}
|
|
411
|
+
|
|
397
412
|
<div>
|
|
398
413
|
<SectionLabel>Info</SectionLabel>
|
|
399
414
|
<div className="divide-y divide-border/40 rounded-lg border border-border/50 bg-muted/20">
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { formatElapsed } from "../../utils"
|
|
3
|
+
import { statusHistorySpans } from "./worker-status-history"
|
|
4
|
+
|
|
5
|
+
const T0 = Date.parse("2026-09-22T10:00:00Z")
|
|
6
|
+
const at = (ms: number) => new Date(T0 + ms).toISOString()
|
|
7
|
+
|
|
8
|
+
describe("statusHistorySpans", () => {
|
|
9
|
+
it("un séjour dure jusqu'à la transition suivante, le dernier jusqu'à now", () => {
|
|
10
|
+
const spans = statusHistorySpans(
|
|
11
|
+
[
|
|
12
|
+
{ seq: 1, at: at(0), from: null, to: "provisioning", source: "spawn" },
|
|
13
|
+
{ seq: 2, at: at(800), from: "provisioning", to: "building", source: "spawn" },
|
|
14
|
+
{ seq: 3, at: at(660_800), from: "building", to: "ready", source: "agent-event" },
|
|
15
|
+
],
|
|
16
|
+
T0 + 3_600_000
|
|
17
|
+
)
|
|
18
|
+
expect(spans.map((s) => s.duration)).toEqual([800, 660_000, 3_600_000 - 660_800])
|
|
19
|
+
expect(spans.map((s) => s.current)).toEqual([false, false, true])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("remet dans l'ordre un journal qui arrive dans le désordre", () => {
|
|
23
|
+
const spans = statusHistorySpans(
|
|
24
|
+
[
|
|
25
|
+
{ seq: 2, at: at(1000), to: "ready" },
|
|
26
|
+
{ seq: 1, at: at(0), to: "starting" },
|
|
27
|
+
],
|
|
28
|
+
T0 + 2000
|
|
29
|
+
)
|
|
30
|
+
expect(spans.map((s) => s.event.to)).toEqual(["starting", "ready"])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("ignore une date illisible et ne produit jamais de durée négative", () => {
|
|
34
|
+
const spans = statusHistorySpans(
|
|
35
|
+
[
|
|
36
|
+
{ seq: 1, at: "pas une date", to: "starting" },
|
|
37
|
+
{ seq: 2, at: at(5000), to: "ready" },
|
|
38
|
+
],
|
|
39
|
+
T0 // horloge en retard sur le journal
|
|
40
|
+
)
|
|
41
|
+
expect(spans).toHaveLength(1)
|
|
42
|
+
expect(spans[0].duration).toBe(0)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("ne fait battre que le séjour en cours", () => {
|
|
46
|
+
const spans = statusHistorySpans(
|
|
47
|
+
[
|
|
48
|
+
{ seq: 1, at: at(0), to: "ready" },
|
|
49
|
+
{ seq: 2, at: at(1000), to: "stopping" },
|
|
50
|
+
{ seq: 3, at: at(2000), to: "stopped" },
|
|
51
|
+
{ seq: 4, at: at(3000), to: "ready" },
|
|
52
|
+
],
|
|
53
|
+
T0 + 4000
|
|
54
|
+
)
|
|
55
|
+
expect(spans.map((s) => s.status.running)).toEqual([false, false, false, true])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("dit pourquoi à partir du meta, et retire les points de suspension d'un séjour fini", () => {
|
|
59
|
+
const spans = statusHistorySpans(
|
|
60
|
+
[
|
|
61
|
+
{ seq: 1, at: at(0), to: "provisioning" },
|
|
62
|
+
{ seq: 2, at: at(1000), to: "starting" },
|
|
63
|
+
{ seq: 3, at: at(2000), to: "exited", meta: { reason: "oom-killed", exitCode: 137, oomKilled: true } },
|
|
64
|
+
],
|
|
65
|
+
T0 + 3000
|
|
66
|
+
)
|
|
67
|
+
expect(spans.map((s) => s.label)).toEqual(["Provisioning", "Setting up", "Exited"])
|
|
68
|
+
expect(spans[2].status.detail).toBe("Ran out of memory (exit 137)")
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe("formatElapsed", () => {
|
|
73
|
+
it.each([
|
|
74
|
+
[800, "800ms"],
|
|
75
|
+
[40_000, "40s"],
|
|
76
|
+
[252_000, "4m 12s"],
|
|
77
|
+
[660_000, "11m"],
|
|
78
|
+
[11_520_000, "3h 12m"],
|
|
79
|
+
[7_200_000, "2h"],
|
|
80
|
+
[59_800, "1m"],
|
|
81
|
+
[273_600_000, "3d 4h"],
|
|
82
|
+
])("%d ms → %s", (ms, out) => {
|
|
83
|
+
expect(formatElapsed(ms)).toBe(out)
|
|
84
|
+
})
|
|
85
|
+
})
|