@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.6
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 +20 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +5 -0
- package/dist/core/engine/prompts.js +42 -0
- package/dist/core/engine/tool-bridge.js +159 -61
- package/dist/core/hooks.js +415 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/permission.js +221 -116
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/session.js +399 -0
- package/dist/core/repl/slash-commands.js +116 -0
- package/dist/core/session.js +168 -0
- package/dist/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/runtime/cli.js +158 -46
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/input-box.js +91 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +68 -0
- package/dist/tui/repl-render.js +218 -0
- package/dist/tui/repl.js +152 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +58 -0
- package/package.json +11 -5
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JobRegistry — Sprint α5.9 (ADR-0056 PR-PUGI-CLI-M1-GAP-J).
|
|
3
|
+
*
|
|
4
|
+
* First-class persistent registry for background bash jobs. Lifts the
|
|
5
|
+
* inline `~/.pugi/jobs.json` management from `src/tools/bash.ts` and
|
|
6
|
+
* extends it with:
|
|
7
|
+
*
|
|
8
|
+
* - Atomic write (tempfile + rename) so concurrent CLI invocations
|
|
9
|
+
* cannot corrupt the ledger (Code Reviewer P2 retro on PR #306).
|
|
10
|
+
* - Stale-job reaping on every `list()` call: entries whose pid no
|
|
11
|
+
* longer corresponds to a live process are marked `abandoned`.
|
|
12
|
+
* - 24h retention for finished / killed / abandoned entries; older
|
|
13
|
+
* records are dropped silently.
|
|
14
|
+
* - Lifecycle status field (`running` / `finished` / `killed` /
|
|
15
|
+
* `failed` / `abandoned`) so the `pugi jobs` CLI can render an
|
|
16
|
+
* accurate snapshot regardless of which CLI invocation observed
|
|
17
|
+
* the exit.
|
|
18
|
+
*
|
|
19
|
+
* Storage stays at `~/.pugi/jobs.json` as a JSON array (not JSONL) so
|
|
20
|
+
* α5.2 readers continue to work. The schema is additive: legacy
|
|
21
|
+
* entries lacking `id` / `status` / etc. are upgraded on read.
|
|
22
|
+
*/
|
|
23
|
+
import { randomUUID } from 'node:crypto';
|
|
24
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, } from 'node:fs';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { dirname, join } from 'node:path';
|
|
27
|
+
/**
|
|
28
|
+
* Retention window for non-`running` entries. 24 hours mirrors the
|
|
29
|
+
* default session window in `core/session.ts`; older jobs are dropped
|
|
30
|
+
* to keep the ledger bounded.
|
|
31
|
+
*/
|
|
32
|
+
export const JOB_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
33
|
+
/**
|
|
34
|
+
* Default grace period between SIGTERM and SIGKILL. Matches the bash
|
|
35
|
+
* tool's BASH_SIGKILL_GRACE_MS so foreground / background paths share
|
|
36
|
+
* the same shutdown contract.
|
|
37
|
+
*/
|
|
38
|
+
export const JOB_DEFAULT_GRACEFUL_MS = 5_000;
|
|
39
|
+
const DEFAULT_JOBS_PATH = join(homedir(), '.pugi', 'jobs.json');
|
|
40
|
+
/**
|
|
41
|
+
* Indirection point so tests can isolate the registry to a tmpdir
|
|
42
|
+
* without touching the user's real `~/.pugi/jobs.json`. The override
|
|
43
|
+
* lives at the module scope (single global) because the registry
|
|
44
|
+
* itself is a singleton — tests reset between cases with
|
|
45
|
+
* `resetJobRegistryForTesting`.
|
|
46
|
+
*/
|
|
47
|
+
let jobsPathOverride;
|
|
48
|
+
let singleton;
|
|
49
|
+
export function getJobRegistry() {
|
|
50
|
+
if (!singleton) {
|
|
51
|
+
singleton = new FileJobRegistry(resolveJobsPath());
|
|
52
|
+
}
|
|
53
|
+
return singleton;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Test-only hook. Resets the singleton so the next `getJobRegistry()`
|
|
57
|
+
* call binds to the (possibly overridden) jobs path again.
|
|
58
|
+
*/
|
|
59
|
+
export function resetJobRegistryForTesting(jobsPath) {
|
|
60
|
+
jobsPathOverride = jobsPath;
|
|
61
|
+
singleton = undefined;
|
|
62
|
+
}
|
|
63
|
+
function resolveJobsPath() {
|
|
64
|
+
return jobsPathOverride ?? DEFAULT_JOBS_PATH;
|
|
65
|
+
}
|
|
66
|
+
class FileJobRegistry {
|
|
67
|
+
path;
|
|
68
|
+
constructor(path) {
|
|
69
|
+
this.path = path;
|
|
70
|
+
}
|
|
71
|
+
async list() {
|
|
72
|
+
return this.listSync();
|
|
73
|
+
}
|
|
74
|
+
listSync() {
|
|
75
|
+
const entries = this.readAll();
|
|
76
|
+
const reaped = this.reapInMemory(entries);
|
|
77
|
+
const pruned = this.pruneOld(reaped);
|
|
78
|
+
if (mutated(entries, pruned)) {
|
|
79
|
+
this.writeAll(pruned);
|
|
80
|
+
}
|
|
81
|
+
return pruned;
|
|
82
|
+
}
|
|
83
|
+
async get(id) {
|
|
84
|
+
const entries = await this.list();
|
|
85
|
+
return entries.find((entry) => entry.id === id);
|
|
86
|
+
}
|
|
87
|
+
async add(input) {
|
|
88
|
+
const entry = {
|
|
89
|
+
...input,
|
|
90
|
+
startedAt: new Date().toISOString(),
|
|
91
|
+
status: 'running',
|
|
92
|
+
};
|
|
93
|
+
const entries = this.readAll();
|
|
94
|
+
entries.push(entry);
|
|
95
|
+
this.writeAll(entries);
|
|
96
|
+
return entry;
|
|
97
|
+
}
|
|
98
|
+
async kill(id, opts = {}) {
|
|
99
|
+
const entries = this.readAll();
|
|
100
|
+
const target = entries.find((entry) => entry.id === id);
|
|
101
|
+
if (!target) {
|
|
102
|
+
return { killed: false, method: 'noop' };
|
|
103
|
+
}
|
|
104
|
+
if (target.status !== 'running') {
|
|
105
|
+
return { killed: false, method: 'noop' };
|
|
106
|
+
}
|
|
107
|
+
const grace = opts.gracefulMs ?? JOB_DEFAULT_GRACEFUL_MS;
|
|
108
|
+
if (!processAlive(target.pid)) {
|
|
109
|
+
this.markStatus(id, 'abandoned');
|
|
110
|
+
return { killed: false, method: 'noop' };
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
process.kill(target.pid, 'SIGTERM');
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
const code = error.code;
|
|
117
|
+
if (code === 'ESRCH') {
|
|
118
|
+
this.markStatus(id, 'abandoned');
|
|
119
|
+
return { killed: false, method: 'noop' };
|
|
120
|
+
}
|
|
121
|
+
return { killed: false, method: 'noop' };
|
|
122
|
+
}
|
|
123
|
+
const sigtermSettled = await waitForExit(target.pid, Math.min(grace, 1_500));
|
|
124
|
+
if (sigtermSettled) {
|
|
125
|
+
this.markStatus(id, 'killed');
|
|
126
|
+
return { killed: true, method: 'SIGTERM' };
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
process.kill(target.pid, 'SIGKILL');
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// already gone between the check and the signal
|
|
133
|
+
}
|
|
134
|
+
await waitForExit(target.pid, 500);
|
|
135
|
+
this.markStatus(id, 'killed');
|
|
136
|
+
return { killed: true, method: 'SIGKILL' };
|
|
137
|
+
}
|
|
138
|
+
async markFinished(id, exitCode) {
|
|
139
|
+
const entries = this.readAll();
|
|
140
|
+
const next = entries.map((entry) => entry.id === id
|
|
141
|
+
? {
|
|
142
|
+
...entry,
|
|
143
|
+
status: (exitCode === 0 ? 'finished' : 'failed'),
|
|
144
|
+
exitCode,
|
|
145
|
+
finishedAt: new Date().toISOString(),
|
|
146
|
+
}
|
|
147
|
+
: entry);
|
|
148
|
+
this.writeAll(next);
|
|
149
|
+
}
|
|
150
|
+
async reapStale() {
|
|
151
|
+
const entries = this.readAll();
|
|
152
|
+
const abandoned = [];
|
|
153
|
+
const next = entries.map((entry) => {
|
|
154
|
+
if (entry.status !== 'running')
|
|
155
|
+
return entry;
|
|
156
|
+
if (processAlive(entry.pid))
|
|
157
|
+
return entry;
|
|
158
|
+
abandoned.push(entry.id);
|
|
159
|
+
return {
|
|
160
|
+
...entry,
|
|
161
|
+
status: 'abandoned',
|
|
162
|
+
finishedAt: new Date().toISOString(),
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
if (abandoned.length > 0) {
|
|
166
|
+
this.writeAll(next);
|
|
167
|
+
}
|
|
168
|
+
return { abandoned };
|
|
169
|
+
}
|
|
170
|
+
markStatus(id, status) {
|
|
171
|
+
const entries = this.readAll();
|
|
172
|
+
const next = entries.map((entry) => entry.id === id
|
|
173
|
+
? {
|
|
174
|
+
...entry,
|
|
175
|
+
status,
|
|
176
|
+
finishedAt: entry.finishedAt ?? new Date().toISOString(),
|
|
177
|
+
}
|
|
178
|
+
: entry);
|
|
179
|
+
this.writeAll(next);
|
|
180
|
+
}
|
|
181
|
+
readAll() {
|
|
182
|
+
if (!existsSync(this.path))
|
|
183
|
+
return [];
|
|
184
|
+
try {
|
|
185
|
+
const raw = readFileSync(this.path, 'utf8');
|
|
186
|
+
if (raw.trim() === '')
|
|
187
|
+
return [];
|
|
188
|
+
const parsed = JSON.parse(raw);
|
|
189
|
+
if (!Array.isArray(parsed))
|
|
190
|
+
return [];
|
|
191
|
+
return parsed
|
|
192
|
+
.map(normalizeEntry)
|
|
193
|
+
.filter((entry) => entry !== undefined);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
writeAll(entries) {
|
|
200
|
+
const dir = dirname(this.path);
|
|
201
|
+
try {
|
|
202
|
+
mkdirSync(dir, { recursive: true });
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// ignore — the open() below surfaces the failure
|
|
206
|
+
}
|
|
207
|
+
// Atomic write: write to a sibling temp file, fsync, then rename
|
|
208
|
+
// onto the target. Rename is atomic on POSIX so concurrent
|
|
209
|
+
// readers either see the previous file or the new file, never a
|
|
210
|
+
// half-written buffer.
|
|
211
|
+
const tempPath = `${this.path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
212
|
+
const body = `${JSON.stringify(entries, null, 2)}\n`;
|
|
213
|
+
let fd;
|
|
214
|
+
try {
|
|
215
|
+
fd = openSync(tempPath, 'w', 0o600);
|
|
216
|
+
writeSync(fd, body);
|
|
217
|
+
fsyncSync(fd);
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
if (fd !== undefined) {
|
|
221
|
+
try {
|
|
222
|
+
closeSync(fd);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// best-effort
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
renameSync(tempPath, this.path);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
// Cleanup the temp file if rename failed for any reason.
|
|
234
|
+
try {
|
|
235
|
+
unlinkSync(tempPath);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// best-effort
|
|
239
|
+
}
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
reapInMemory(entries) {
|
|
244
|
+
return entries.map((entry) => {
|
|
245
|
+
if (entry.status !== 'running')
|
|
246
|
+
return entry;
|
|
247
|
+
if (processAlive(entry.pid))
|
|
248
|
+
return entry;
|
|
249
|
+
return {
|
|
250
|
+
...entry,
|
|
251
|
+
status: 'abandoned',
|
|
252
|
+
finishedAt: entry.finishedAt ?? new Date().toISOString(),
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
pruneOld(entries) {
|
|
257
|
+
const cutoff = Date.now() - JOB_RETENTION_MS;
|
|
258
|
+
return entries.filter((entry) => {
|
|
259
|
+
if (entry.status === 'running')
|
|
260
|
+
return true;
|
|
261
|
+
const stamp = entry.finishedAt ?? entry.startedAt;
|
|
262
|
+
const parsed = Date.parse(stamp);
|
|
263
|
+
if (Number.isNaN(parsed))
|
|
264
|
+
return true;
|
|
265
|
+
return parsed >= cutoff;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Normalize legacy `~/.pugi/jobs.json` entries (Sprint α5.2 shape with
|
|
271
|
+
* `jobId` / `class` / no status) into the JobEntry shape used here.
|
|
272
|
+
* Returns undefined for entries that cannot be migrated so the loop
|
|
273
|
+
* silently skips garbage.
|
|
274
|
+
*/
|
|
275
|
+
function normalizeEntry(candidate) {
|
|
276
|
+
if (typeof candidate !== 'object' || candidate === null)
|
|
277
|
+
return undefined;
|
|
278
|
+
const c = candidate;
|
|
279
|
+
const id = typeof c['id'] === 'string'
|
|
280
|
+
? c['id']
|
|
281
|
+
: typeof c['jobId'] === 'string'
|
|
282
|
+
? c['jobId']
|
|
283
|
+
: undefined;
|
|
284
|
+
if (!id)
|
|
285
|
+
return undefined;
|
|
286
|
+
const pid = typeof c['pid'] === 'number' ? c['pid'] : undefined;
|
|
287
|
+
if (pid === undefined)
|
|
288
|
+
return undefined;
|
|
289
|
+
const command = typeof c['command'] === 'string'
|
|
290
|
+
? c['command']
|
|
291
|
+
: typeof c['cmd'] === 'string'
|
|
292
|
+
? c['cmd']
|
|
293
|
+
: undefined;
|
|
294
|
+
if (command === undefined)
|
|
295
|
+
return undefined;
|
|
296
|
+
const bashClassRaw = typeof c['bashClass'] === 'string'
|
|
297
|
+
? c['bashClass']
|
|
298
|
+
: typeof c['class'] === 'string'
|
|
299
|
+
? c['class']
|
|
300
|
+
: 'unknown';
|
|
301
|
+
const bashClass = bashClassRaw;
|
|
302
|
+
const cwd = typeof c['cwd'] === 'string' ? c['cwd'] : '';
|
|
303
|
+
const startedAt = typeof c['startedAt'] === 'string'
|
|
304
|
+
? c['startedAt']
|
|
305
|
+
: new Date().toISOString();
|
|
306
|
+
const sessionId = typeof c['sessionId'] === 'string' ? c['sessionId'] : 'unknown';
|
|
307
|
+
const status = c['status'] === 'finished' ||
|
|
308
|
+
c['status'] === 'killed' ||
|
|
309
|
+
c['status'] === 'failed' ||
|
|
310
|
+
c['status'] === 'abandoned'
|
|
311
|
+
? c['status']
|
|
312
|
+
: 'running';
|
|
313
|
+
const exitCode = typeof c['exitCode'] === 'number' ? c['exitCode'] : undefined;
|
|
314
|
+
const finishedAt = typeof c['finishedAt'] === 'string' ? c['finishedAt'] : undefined;
|
|
315
|
+
const outputArtifactRef = typeof c['outputArtifactRef'] === 'string'
|
|
316
|
+
? c['outputArtifactRef']
|
|
317
|
+
: undefined;
|
|
318
|
+
return {
|
|
319
|
+
id,
|
|
320
|
+
pid,
|
|
321
|
+
command,
|
|
322
|
+
bashClass,
|
|
323
|
+
cwd,
|
|
324
|
+
startedAt,
|
|
325
|
+
finishedAt,
|
|
326
|
+
status,
|
|
327
|
+
exitCode,
|
|
328
|
+
sessionId,
|
|
329
|
+
outputArtifactRef,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function processAlive(pid) {
|
|
333
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
334
|
+
return false;
|
|
335
|
+
try {
|
|
336
|
+
process.kill(pid, 0);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
const code = error.code;
|
|
341
|
+
// EPERM means the process exists but we cannot signal it — still alive.
|
|
342
|
+
if (code === 'EPERM')
|
|
343
|
+
return true;
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function waitForExit(pid, timeoutMs) {
|
|
348
|
+
const deadline = Date.now() + timeoutMs;
|
|
349
|
+
while (Date.now() < deadline) {
|
|
350
|
+
if (!processAlive(pid))
|
|
351
|
+
return true;
|
|
352
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
353
|
+
}
|
|
354
|
+
return !processAlive(pid);
|
|
355
|
+
}
|
|
356
|
+
function mutated(before, after) {
|
|
357
|
+
if (before.length !== after.length)
|
|
358
|
+
return true;
|
|
359
|
+
for (let i = 0; i < before.length; i++) {
|
|
360
|
+
const a = before[i];
|
|
361
|
+
const b = after[i];
|
|
362
|
+
if (!a || !b)
|
|
363
|
+
return true;
|
|
364
|
+
if (a.id !== b.id)
|
|
365
|
+
return true;
|
|
366
|
+
if (a.status !== b.status)
|
|
367
|
+
return true;
|
|
368
|
+
if (a.exitCode !== b.exitCode)
|
|
369
|
+
return true;
|
|
370
|
+
if (a.finishedAt !== b.finishedAt)
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Format a compact one-line summary for the engine prompt snapshot.
|
|
377
|
+
* Public so the prompts module can pre-format without re-implementing
|
|
378
|
+
* the rendering rules.
|
|
379
|
+
*/
|
|
380
|
+
export function summarizeJobsForPrompt(entries) {
|
|
381
|
+
if (entries.length === 0) {
|
|
382
|
+
return 'BACKGROUND JOBS: none on watch.';
|
|
383
|
+
}
|
|
384
|
+
const running = entries.filter((entry) => entry.status === 'running');
|
|
385
|
+
const finished = entries
|
|
386
|
+
.filter((entry) => entry.status !== 'running')
|
|
387
|
+
.slice(-5);
|
|
388
|
+
const lines = ['BACKGROUND JOBS:'];
|
|
389
|
+
if (running.length === 0) {
|
|
390
|
+
lines.push(' - Running: 0');
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
const oldest = running.reduce((min, entry) => {
|
|
394
|
+
return Date.parse(entry.startedAt) < Date.parse(min.startedAt)
|
|
395
|
+
? entry
|
|
396
|
+
: min;
|
|
397
|
+
}, running[0]);
|
|
398
|
+
lines.push(` - Running: ${running.length} (oldest started ${relativeAge(oldest.startedAt)} ago)`);
|
|
399
|
+
const counts = new Map();
|
|
400
|
+
for (const entry of running) {
|
|
401
|
+
counts.set(entry.bashClass, (counts.get(entry.bashClass) ?? 0) + 1);
|
|
402
|
+
}
|
|
403
|
+
const classSummary = [...counts.entries()]
|
|
404
|
+
.map(([cls, count]) => `${cls} (${count})`)
|
|
405
|
+
.join(', ');
|
|
406
|
+
lines.push(` - Classes in flight: ${classSummary}`);
|
|
407
|
+
}
|
|
408
|
+
if (finished.length > 0) {
|
|
409
|
+
const compact = finished
|
|
410
|
+
.map((entry) => `${entry.id.slice(0, 8)} ${entry.status}${entry.exitCode !== undefined ? ` exit=${entry.exitCode}` : ''}`)
|
|
411
|
+
.join('; ');
|
|
412
|
+
lines.push(` - Recent finished (last ${finished.length}): ${compact}`);
|
|
413
|
+
}
|
|
414
|
+
lines.push(' Use `pugi jobs tail <id>` to inspect output. Do not spawn another long-running background job while 3+ are already on watch.');
|
|
415
|
+
return lines.join('\n');
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Renders an ISO timestamp as a compact relative age (`2m`, `1h 14m`,
|
|
419
|
+
* `3d`). Used by the engine prompt snapshot and the `pugi jobs list`
|
|
420
|
+
* table rendering.
|
|
421
|
+
*/
|
|
422
|
+
export function relativeAge(iso) {
|
|
423
|
+
const ms = Math.max(0, Date.now() - Date.parse(iso));
|
|
424
|
+
const seconds = Math.floor(ms / 1000);
|
|
425
|
+
if (seconds < 60)
|
|
426
|
+
return `${seconds}s`;
|
|
427
|
+
const minutes = Math.floor(seconds / 60);
|
|
428
|
+
if (minutes < 60)
|
|
429
|
+
return `${minutes}m`;
|
|
430
|
+
const hours = Math.floor(minutes / 60);
|
|
431
|
+
const remMinutes = minutes - hours * 60;
|
|
432
|
+
if (hours < 24) {
|
|
433
|
+
return remMinutes === 0 ? `${hours}h` : `${hours}h ${remMinutes}m`;
|
|
434
|
+
}
|
|
435
|
+
const days = Math.floor(hours / 24);
|
|
436
|
+
const remHours = hours - days * 24;
|
|
437
|
+
return remHours === 0 ? `${days}d` : `${days}d ${remHours}h`;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Renders a finished-duration in the same units as `relativeAge` but
|
|
441
|
+
* computed from a startedAt + finishedAt pair. Falls back to `?` when
|
|
442
|
+
* the timestamps cannot be parsed.
|
|
443
|
+
*/
|
|
444
|
+
export function formatDuration(startedAt, finishedAt) {
|
|
445
|
+
const start = Date.parse(startedAt);
|
|
446
|
+
const end = finishedAt ? Date.parse(finishedAt) : Date.now();
|
|
447
|
+
if (Number.isNaN(start) || Number.isNaN(end) || end < start)
|
|
448
|
+
return '?';
|
|
449
|
+
const ms = end - start;
|
|
450
|
+
const seconds = Math.floor(ms / 1000);
|
|
451
|
+
if (seconds < 60)
|
|
452
|
+
return `${seconds}s`;
|
|
453
|
+
const minutes = Math.floor(seconds / 60);
|
|
454
|
+
const remSeconds = seconds - minutes * 60;
|
|
455
|
+
if (minutes < 60) {
|
|
456
|
+
return remSeconds === 0 ? `${minutes}m` : `${minutes}m ${remSeconds}s`;
|
|
457
|
+
}
|
|
458
|
+
const hours = Math.floor(minutes / 60);
|
|
459
|
+
const remMinutes = minutes - hours * 60;
|
|
460
|
+
return remMinutes === 0 ? `${hours}h` : `${hours}h ${remMinutes}m`;
|
|
461
|
+
}
|
|
462
|
+
//# sourceMappingURL=registry.js.map
|