@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.
Files changed (32) hide show
  1. package/README.md +35 -1
  2. package/package.json +6 -1
  3. package/src/components/sheet.tsx +7 -1
  4. package/src/components/tasks/agent-markdown.tsx +322 -0
  5. package/src/components/tasks/attachments.ts +152 -0
  6. package/src/components/tasks/command-log.tsx +102 -0
  7. package/src/components/tasks/composer.tsx +252 -0
  8. package/src/components/tasks/conversation-list.tsx +238 -0
  9. package/src/components/tasks/diff-panel.tsx +534 -0
  10. package/src/components/tasks/event-stream.tsx +860 -0
  11. package/src/components/tasks/index.ts +111 -0
  12. package/src/components/tasks/machine.tsx +455 -0
  13. package/src/components/tasks/new-task-dialog.tsx +301 -0
  14. package/src/components/tasks/pull-request.tsx +179 -0
  15. package/src/components/tasks/session-usage.tsx +165 -0
  16. package/src/components/tasks/task-attachments.tsx +475 -0
  17. package/src/components/tasks/task-cockpit-mobile.tsx +272 -0
  18. package/src/components/tasks/task-cockpit.tsx +225 -0
  19. package/src/components/tasks/task-details.tsx +239 -0
  20. package/src/components/tasks/task-list.tsx +272 -0
  21. package/src/components/tasks/task-state.tsx +180 -0
  22. package/src/components/tasks/tasks.test.tsx +205 -0
  23. package/src/components/tasks/tool-stack.tsx +692 -0
  24. package/src/components/tasks/types.ts +220 -0
  25. package/src/components/tasks/worker-link.tsx +126 -0
  26. package/src/components/workers/index.ts +5 -2
  27. package/src/components/workers/types.ts +22 -0
  28. package/src/components/workers/worker-cockpit.tsx +17 -2
  29. package/src/components/workers/worker-status-history.test.ts +85 -0
  30. package/src/components/workers/worker-status-history.tsx +166 -0
  31. package/src/index.ts +1 -1
  32. 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
+ })