@lionrapid/cli 0.2.2 → 0.3.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/dist/cli.js CHANGED
@@ -15,10 +15,12 @@ import { ConfigError, ApiKeyError, resolveApiKey, listCredentialServers, findCon
15
15
  import { ApiError, NetworkError, LionRapidApiClient } from './api-client.js';
16
16
  import { ExitCode, exitCodeForError } from './exit-codes.js';
17
17
  import { emitJson, errorEnvelope } from './output.js';
18
+ import { defaultJobHistoryPath, recordJobs, } from './job-history.js';
18
19
  import { loadContext, loadOfflineContext } from './commands/context.js';
19
20
  import { runInit, fetchLocaleDefaults, detectLocalesOnDisk } from './commands/init.js';
20
21
  import { runLogin, runLogout, promptHidden, DEFAULT_SERVER } from './commands/login.js';
21
22
  import { runPush } from './commands/push.js';
23
+ import { runStatus, explainStatus } from './commands/status.js';
22
24
  import { runPull } from './commands/pull.js';
23
25
  import { runValidate } from './commands/validate.js';
24
26
  import { runTypesGenerate, runTypesGenerateServer, runTypesCheck } from './commands/types.js';
@@ -58,11 +60,152 @@ function fail(err, ctx = {}) {
58
60
  else {
59
61
  console.error(`✖ ${err instanceof Error ? err.message : String(err)}`);
60
62
  }
63
+ // Anything still true despite the failure — e.g. batches that were accepted
64
+ // before it. Printed after the error so the cause reads first.
65
+ for (const note of ctx.notes ?? [])
66
+ console.error(note);
61
67
  if (ctx.json) {
62
68
  process.stdout.write(`${JSON.stringify(errorEnvelope(ctx.command ?? 'lionrapid', err), null, 2)}\n`);
63
69
  }
64
70
  process.exit(exitCodeForError(err));
65
71
  }
72
+ /**
73
+ * Save this push's receipts to `~/.lionrapid/jobs.json`. Called on the success
74
+ * path AND from push's catch: a batch that was accepted before a later failure
75
+ * is still queued on the server, and its id is the only way to find out how it
76
+ * ended.
77
+ */
78
+ function saveQueuedJobs(jobs, ctx) {
79
+ const pushedAt = new Date().toISOString();
80
+ const entries = jobs.map((job) => ({
81
+ id: job.id,
82
+ server: ctx.config.server,
83
+ project: ctx.config.project,
84
+ namespace: job.namespace,
85
+ locale: ctx.config.sourceLocale,
86
+ files: [job.file],
87
+ keys: job.keys,
88
+ pushedAt,
89
+ status: job.status,
90
+ }));
91
+ recordJobs(entries);
92
+ return entries;
93
+ }
94
+ /** `2026-08-05 14:21:07` in local time, or the raw value if it isn't a date. */
95
+ function when(iso) {
96
+ if (!iso)
97
+ return '—';
98
+ const t = Date.parse(iso);
99
+ if (Number.isNaN(t))
100
+ return iso;
101
+ const d = new Date(t);
102
+ const pad = (n) => String(n).padStart(2, '0');
103
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
104
+ }
105
+ /** "128 key(s) from locales/en.json → app" — what a job is carrying. */
106
+ function jobPayload(view) {
107
+ const h = view.history;
108
+ if (!h)
109
+ return 'pushed from another machine or checkout';
110
+ return `${h.keys} key(s) from ${h.files.join(', ')} → ${h.namespace}`;
111
+ }
112
+ /** The detail view for `lionrapid status <job-id>`. */
113
+ function printJobDetail(view, out) {
114
+ const { icon, label, detail } = explainStatus(view);
115
+ out(`${icon} job ${view.id} — ${label}`);
116
+ out(` what ${jobPayload(view)}`);
117
+ if (view.history)
118
+ out(` pushed ${when(view.history.pushedAt)}`);
119
+ out(` verdict ${detail}`);
120
+ const job = view.job;
121
+ if (job?.deadLetterReason)
122
+ out(` reason ${job.deadLetterReason}`);
123
+ else if (view.history?.deadLetterReason)
124
+ out(` reason ${view.history.deadLetterReason}`);
125
+ const error = job?.error ?? view.history?.error;
126
+ if (error)
127
+ out(` error ${error}`);
128
+ if (job && job.retryCount > 0) {
129
+ out(` attempts ${job.retryCount} of ${job.maxRetries}`);
130
+ for (const e of job.retryErrors)
131
+ out(` · ${e}`);
132
+ }
133
+ if (job?.nextRetryAt)
134
+ out(` next try ${when(job.nextRetryAt)}`);
135
+ if (job?.completedAt)
136
+ out(` finished ${when(job.completedAt)}`);
137
+ // Never let a remembered status read as a live one.
138
+ if (view.source === 'local') {
139
+ out(` ⚠ last known on this machine${view.history?.checkedAt ? ` (checked ${when(view.history.checkedAt)})` : ''} — not confirmed with the server just now`);
140
+ }
141
+ else if (view.source === 'local-final') {
142
+ out(' · answered from this machine\'s record — the state is final, so no server call was needed');
143
+ }
144
+ if (label === 'dead-lettered') {
145
+ out(' next: fix the cause, then re-run `lionrapid push` — pushes upsert, so re-running is safe.');
146
+ }
147
+ else if (label === 'queued' || label === 'processing' || label === 'retrying') {
148
+ out(` next: \`lionrapid status ${view.id}\` again in a moment.`);
149
+ }
150
+ }
151
+ /** The listing for bare `lionrapid status` / `lionrapid status list`. */
152
+ function printJobList(res, out) {
153
+ out(`· ${res.jobs.length} job(s) pushed from this machine, newest first:`);
154
+ for (const view of res.jobs) {
155
+ const { icon, label } = explainStatus(view);
156
+ const h = view.history;
157
+ const where = h ? `${h.files.join(', ')} → ${h.namespace} · ${h.keys} key(s)` : '';
158
+ const stale = view.source === 'local' ? ' (last known)' : '';
159
+ out(` ${icon} ${view.id} ${label.padEnd(14)}${where} · ${when(h?.pushedAt)}${stale}`);
160
+ }
161
+ out(' next: `lionrapid status <job-id>` for the full story on one of them.');
162
+ }
163
+ /** Human rendering of a status result. Machine callers use `--json`. */
164
+ function printStatus(res, out) {
165
+ if (res.notFound) {
166
+ out('✖ no such job — the server does not know that id, and this machine has no record of it.');
167
+ out(' Finished jobs are kept about an hour server-side; older ones survive only in');
168
+ out(` ${res.historyPath}.`);
169
+ if (res.offline)
170
+ out(` (the server could not be asked: ${res.offline})`);
171
+ return;
172
+ }
173
+ if (res.mode === 'job') {
174
+ const view = res.jobs[0];
175
+ if (view)
176
+ printJobDetail(view, out);
177
+ return;
178
+ }
179
+ if (res.jobs.length > 0) {
180
+ printJobList(res, out);
181
+ if (res.offline) {
182
+ out(` ⚠ the server could not be reached (${res.offline}) — every line above is last known.`);
183
+ }
184
+ return;
185
+ }
186
+ // Nothing local: say so plainly, then show whatever the server will tell us.
187
+ out(`· no pushes recorded on this machine (${res.historyPath})`);
188
+ if (res.queue) {
189
+ const counts = Object.entries(res.queue.statusCounts)
190
+ .map(([k, v]) => `${v} ${k}`)
191
+ .join(', ');
192
+ out(` server queue: depth ${res.queue.queueDepth}${counts ? ` · ${counts}` : ''} · worker ${res.queue.workerRunning ? 'running' : 'NOT running'}`);
193
+ }
194
+ if (res.deadLettered && res.deadLettered.length > 0) {
195
+ out(` ✖ dead-letter queue: ${res.deadLettered.length} job(s) the backend gave up on:`);
196
+ for (const job of res.deadLettered) {
197
+ out(` ${job.jobId} ${job.deadLetterReason ?? job.error ?? 'no reason given'}`);
198
+ }
199
+ }
200
+ else if (res.deadLettered) {
201
+ out(' ✓ dead-letter queue is empty — nothing was dropped.');
202
+ }
203
+ if (res.queueUnavailable)
204
+ out(` · queue view unavailable: ${res.queueUnavailable}`);
205
+ if (res.offline)
206
+ out(` · the server could not be reached: ${res.offline}`);
207
+ out(' next: run `lionrapid push`, then `lionrapid status`.');
208
+ }
66
209
  /**
67
210
  * One-line interactive prompt on stderr with a default. Non-TTY (CI, pipes)
68
211
  * never blocks — it returns the default immediately.
@@ -295,13 +438,19 @@ export function buildProgram() {
295
438
  .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
296
439
  .option('-c, --config <path>', 'path to lionrapid.yml')
297
440
  .action(async (options) => {
441
+ // Receipts are collected as they arrive, not from the result, so a
442
+ // total failure (which rethrows) still saves the ids of the batches
443
+ // that DID reach the server.
444
+ const queued = [];
445
+ let ctx;
298
446
  try {
299
- const ctx = loadContext({ config: options.config });
447
+ ctx = loadContext({ config: options.config });
300
448
  const res = await runPush(ctx.config, ctx.client, {
301
449
  cwd: ctx.cwd,
302
450
  target: options.target,
303
451
  publish: options.publish,
304
452
  dryRun: options.dryRun,
453
+ onJob: (job) => queued.push(job),
305
454
  });
306
455
  const out = options.json ? console.error : console.log;
307
456
  const tag = res.dryRun ? '[dry-run] would push' : '✓ pushed';
@@ -322,13 +471,70 @@ export function buildProgram() {
322
471
  out('⚠ uploaded as drafts but not published — publishing needs an editor session token.\n' +
323
472
  ' Set LIONRAPID_EDITOR_TOKEN, or publish in the dashboard.');
324
473
  }
474
+ // The whole point: "pushed" above is what we SENT. The server stores
475
+ // it in the background, so print what to watch and where it is saved.
476
+ if (res.jobs.length > 0) {
477
+ saveQueuedJobs(res.jobs, ctx);
478
+ out(`⧗ queued as ${res.jobs.length} background job(s) — the server stores these rows\n` +
479
+ ' asynchronously, so "pushed" means sent, not yet stored:');
480
+ for (const job of res.jobs) {
481
+ out(` ${job.id} ${job.file} (${job.keys} key(s))`);
482
+ }
483
+ out(` next: lionrapid status ${res.jobs[0].id}`);
484
+ out(` (saved to ${defaultJobHistoryPath()} — bare \`lionrapid status\` lists them)`);
485
+ }
486
+ else if (res.mode === 'source' && !res.dryRun && res.pushed > 0) {
487
+ // Accepted, but no ids came back — an older server or a proxy that
488
+ // ate the body. Say it rather than implying everything is confirmed.
489
+ out('⚠ the server accepted the batches but returned no job ids, so there is nothing\n' +
490
+ ' to poll — `lionrapid validate` is the way to confirm these rows landed.');
491
+ }
325
492
  if (options.json)
326
493
  emitJson('push', failed.length === 0, res);
327
494
  if (failed.length > 0)
328
495
  process.exit(ExitCode.PARTIAL);
329
496
  }
330
497
  catch (err) {
331
- fail(err, { json: options.json, command: 'push' });
498
+ // Batches accepted before the failure are still queued on the server.
499
+ // Save their ids and say so — the whole point is never being blind.
500
+ const notes = [];
501
+ if (ctx && queued.length > 0) {
502
+ saveQueuedJobs(queued, ctx);
503
+ notes.push(` · ${queued.length} batch(es) had already been queued before this failure:`, ...queued.map((job) => ` ${job.id} ${job.file} (${job.keys} key(s))`), ' Run `lionrapid status` to see how they ended.');
504
+ }
505
+ fail(err, { json: options.json, command: 'push', notes });
506
+ }
507
+ });
508
+ program
509
+ .command('status')
510
+ .argument('[job-id]', 'a job id from `push` (omit, or pass "list", for every recorded job)')
511
+ .description('Show what the server actually did with the strings `push` queued')
512
+ .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
513
+ .option('-c, --config <path>', 'path to lionrapid.yml')
514
+ .action(async (jobId, options) => {
515
+ try {
516
+ // `status` must answer from anywhere: run outside a project, or with no
517
+ // saved key, it falls back to the on-disk history instead of failing.
518
+ let ctx;
519
+ try {
520
+ ctx = loadContext({ config: options.config });
521
+ }
522
+ catch {
523
+ ctx = undefined;
524
+ }
525
+ const res = await runStatus({
526
+ jobId,
527
+ client: ctx?.client,
528
+ server: ctx?.config.server,
529
+ });
530
+ printStatus(res, options.json ? console.error : console.log);
531
+ if (options.json)
532
+ emitJson('status', !res.notFound, res);
533
+ if (res.notFound)
534
+ process.exit(ExitCode.VALIDATION);
535
+ }
536
+ catch (err) {
537
+ fail(err, { json: options.json, command: 'status' });
332
538
  }
333
539
  });
334
540
  program
@@ -5,11 +5,34 @@
5
5
  * rows via `sync/batch`; the server then queues machine translation for the
6
6
  * configured target locales.
7
7
  *
8
+ * `sync/batch` answers 202 Accepted — the rows are QUEUED, not yet stored. So
9
+ * every batch also yields job ids ({@link PushedJob}), which is the only thing
10
+ * that lets a caller tell "the backend stored this" from "the backend dropped
11
+ * it". Without them a push that dead-letters looks exactly like one that worked.
12
+ *
8
13
  * `--target <locale>` mode uploads an already-translated target file as drafts
9
14
  * via `targets/batch`, optionally publishing them with `--publish`.
10
15
  */
11
16
  import { type LionRapidConfig } from '../config.js';
12
17
  import type { LionRapidApiClient } from '../api-client.js';
18
+ /**
19
+ * A sync job the server queued for one batch of this push.
20
+ *
21
+ * `sync/batch` answers 202 — accepted, not stored. This is the receipt that
22
+ * makes the difference observable: it says which file's keys are riding on
23
+ * which job, so `lionrapid status <id>` can later answer "stored" or "dropped".
24
+ * Target mode has no equivalent because `targets/batch` writes synchronously.
25
+ */
26
+ export interface PushedJob {
27
+ id: string;
28
+ /** The file whose keys were in this batch. */
29
+ file: string;
30
+ namespace: string;
31
+ /** Keys in this batch (a large file is chunked into several jobs). */
32
+ keys: number;
33
+ /** Status the server reported at enqueue time (`pending`). */
34
+ status: string;
35
+ }
13
36
  export interface PushOptions {
14
37
  cwd: string;
15
38
  /** Push an already-translated target locale instead of the source. */
@@ -18,6 +41,11 @@ export interface PushOptions {
18
41
  publish?: boolean;
19
42
  /** Parse + count what would be pushed, but make no network calls. */
20
43
  dryRun?: boolean;
44
+ /**
45
+ * Called as each batch is accepted. The caller persists these, so ids queued
46
+ * before a later total failure are not lost when `runPush` rethrows.
47
+ */
48
+ onJob?: (job: PushedJob) => void;
21
49
  }
22
50
  export interface PushFileResult {
23
51
  file: string;
@@ -28,8 +56,14 @@ export interface PushFileResult {
28
56
  }
29
57
  export interface PushResult {
30
58
  mode: 'source' | 'target';
59
+ /**
60
+ * Keys the CLI SENT. Not the number stored — the server queues them, so
61
+ * `jobs` is what says whether they landed.
62
+ */
31
63
  pushed: number;
32
64
  files: PushFileResult[];
65
+ /** Jobs the server queued for this push (source mode only; empty on a dry run). */
66
+ jobs: PushedJob[];
33
67
  /** True when `--publish` was requested but no editor token was available. */
34
68
  publishSkipped?: boolean;
35
69
  /** True when this was a dry run — nothing was sent to the server. */
@@ -5,6 +5,11 @@
5
5
  * rows via `sync/batch`; the server then queues machine translation for the
6
6
  * configured target locales.
7
7
  *
8
+ * `sync/batch` answers 202 Accepted — the rows are QUEUED, not yet stored. So
9
+ * every batch also yields job ids ({@link PushedJob}), which is the only thing
10
+ * that lets a caller tell "the backend stored this" from "the backend dropped
11
+ * it". Without them a push that dead-letters looks exactly like one that worked.
12
+ *
8
13
  * `--target <locale>` mode uploads an already-translated target file as drafts
9
14
  * via `targets/batch`, optionally publishing them with `--publish`.
10
15
  */
@@ -29,6 +34,7 @@ export async function runPush(config, client, opts) {
29
34
  'it in the dashboard before pushing target translations.');
30
35
  }
31
36
  const files = [];
37
+ const jobs = [];
32
38
  // Publishing (mark-ready) needs an editor token; if asked to publish without
33
39
  // one, we still upload the drafts and report that publishing was skipped.
34
40
  const canPublish = mode === 'target' && opts.publish === true && client.canPublish;
@@ -56,7 +62,22 @@ export async function runPush(config, client, opts) {
56
62
  if (!opts.dryRun) {
57
63
  for (const batch of chunk(items, BATCH)) {
58
64
  if (mode === 'source') {
59
- await client.syncBatch(config.sourceLocale, namespace, batch);
65
+ const queued = await client.syncBatch(config.sourceLocale, namespace, batch);
66
+ // Every id, not just job_id: a batch spanning several locales queues
67
+ // one job each, and the ones after the first would otherwise be
68
+ // unobservable. Reported as they happen so a later total failure
69
+ // cannot take the earlier receipts down with it.
70
+ for (const id of queued.job_ids) {
71
+ const job = {
72
+ id,
73
+ file: relPath,
74
+ namespace,
75
+ keys: batch.length,
76
+ status: queued.status || 'pending',
77
+ };
78
+ jobs.push(job);
79
+ opts.onJob?.(job);
80
+ }
60
81
  }
61
82
  else {
62
83
  await client.targetsBatch(opts.target, namespace, batch);
@@ -90,6 +111,7 @@ export async function runPush(config, client, opts) {
90
111
  mode,
91
112
  pushed,
92
113
  files,
114
+ jobs,
93
115
  ...(publishSkipped ? { publishSkipped: true } : {}),
94
116
  ...(opts.dryRun ? { dryRun: true } : {}),
95
117
  };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `status` — answer the question a 202 leaves open: did the backend actually
3
+ * store what `push` sent?
4
+ *
5
+ * `push` reports what the CLI put on the wire. The server queues that work and
6
+ * answers 202, so a clean push is still compatible with the job failing later.
7
+ * This command closes the loop three ways:
8
+ *
9
+ * status <job-id> → GET /sync/jobs/{id}, the server's own verdict
10
+ * status [list] → every job this machine pushed, newest first
11
+ * (nothing local) → fall back to queue-health + the dead-letter queue
12
+ *
13
+ * Everything degrades. With no network, the local history is still shown and
14
+ * every line is marked as last-known rather than current; with no config or API
15
+ * key (running from some other directory) the disk view still works.
16
+ */
17
+ import { type LionRapidApiClient, type QueueHealth, type SyncJobStatus } from '../api-client.js';
18
+ import { type JobHistoryEntry } from '../job-history.js';
19
+ /** Where a line's status came from — the difference between "is" and "was". */
20
+ export type StatusSource =
21
+ /** Fetched from the server just now. */
22
+ 'server'
23
+ /** Read from `~/.lionrapid/jobs.json` — last known, not current. */
24
+ | 'local'
25
+ /** Terminal on disk: last known IS current, the server cannot change it. */
26
+ | 'local-final';
27
+ export interface JobView {
28
+ id: string;
29
+ status: string;
30
+ source: StatusSource;
31
+ /** What this machine recorded when it pushed (absent for a server-only job). */
32
+ history?: JobHistoryEntry;
33
+ /** The server's answer (absent when offline, or when answered from disk). */
34
+ job?: SyncJobStatus;
35
+ }
36
+ export interface StatusResult {
37
+ mode: 'job' | 'list';
38
+ jobs: JobView[];
39
+ /** Path of the history file the listing came from. */
40
+ historyPath: string;
41
+ /** Set when the server could not be reached — everything shown is last-known. */
42
+ offline?: string;
43
+ /** Queue fallback, only when there is nothing local to show. */
44
+ queue?: QueueHealth;
45
+ deadLettered?: SyncJobStatus[];
46
+ /** Why the queue fallback is missing (e.g. the key lacks `ops:queue`). */
47
+ queueUnavailable?: string;
48
+ /** `status <id>`: the id is known neither to the server nor to this machine. */
49
+ notFound?: boolean;
50
+ }
51
+ export interface StatusOptions {
52
+ /** A job id, `list`, or nothing (both of the latter mean "list"). */
53
+ jobId?: string;
54
+ /** Override the history file (tests; also keeps a push's receipts together). */
55
+ historyPath?: string;
56
+ /**
57
+ * The API client. Omitted when there is no config/API key here — the command
58
+ * then reports from disk only.
59
+ */
60
+ client?: LionRapidApiClient;
61
+ /** Limit the listing to jobs pushed to this server. */
62
+ server?: string;
63
+ }
64
+ /** True for the bare command and the explicit `list` form. */
65
+ export declare function isListRequest(jobId?: string): boolean;
66
+ export declare function runStatus(opts?: StatusOptions): Promise<StatusResult>;
67
+ /**
68
+ * One-line verdict for a job state, in the terms the incident was about: were
69
+ * these rows stored or not. Shared by the detail and list renderings.
70
+ */
71
+ export declare function explainStatus(view: JobView): {
72
+ icon: string;
73
+ label: string;
74
+ detail: string;
75
+ };
76
+ //# sourceMappingURL=status.d.ts.map
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `status` — answer the question a 202 leaves open: did the backend actually
3
+ * store what `push` sent?
4
+ *
5
+ * `push` reports what the CLI put on the wire. The server queues that work and
6
+ * answers 202, so a clean push is still compatible with the job failing later.
7
+ * This command closes the loop three ways:
8
+ *
9
+ * status <job-id> → GET /sync/jobs/{id}, the server's own verdict
10
+ * status [list] → every job this machine pushed, newest first
11
+ * (nothing local) → fall back to queue-health + the dead-letter queue
12
+ *
13
+ * Everything degrades. With no network, the local history is still shown and
14
+ * every line is marked as last-known rather than current; with no config or API
15
+ * key (running from some other directory) the disk view still works.
16
+ */
17
+ import { ApiError, NetworkError, } from '../api-client.js';
18
+ import { defaultJobHistoryPath, isTerminalJobStatus, readJobHistory, updateJobStatuses, } from '../job-history.js';
19
+ import { mapLimit } from './context.js';
20
+ /**
21
+ * How many stale (non-terminal) jobs a bare `status` re-checks against the
22
+ * server. A listing is a glance, not a poll loop: the newest unresolved jobs
23
+ * are the ones anyone is actually waiting on, and the rest still print from
24
+ * disk — labelled as such, never silently stale.
25
+ */
26
+ const MAX_REFRESH = 20;
27
+ const REFRESH_CONCURRENCY = 4;
28
+ /** True for the bare command and the explicit `list` form. */
29
+ export function isListRequest(jobId) {
30
+ return jobId === undefined || jobId === 'list';
31
+ }
32
+ export async function runStatus(opts = {}) {
33
+ const historyPath = opts.historyPath ?? defaultJobHistoryPath();
34
+ const history = readJobHistory(historyPath);
35
+ return isListRequest(opts.jobId)
36
+ ? listJobs(history, historyPath, opts)
37
+ : showJob(opts.jobId, history, historyPath, opts);
38
+ }
39
+ /** `status <job-id>` — the server's verdict, or the local receipt if it can't answer. */
40
+ async function showJob(jobId, history, historyPath, opts) {
41
+ const recorded = history.find((e) => e.id === jobId);
42
+ const base = { mode: 'job', jobs: [], historyPath };
43
+ // Already final: the server cannot change its mind, and after ~1h it has
44
+ // forgotten the job entirely. Answer from the receipt and skip the call.
45
+ if (recorded && isTerminalJobStatus(recorded.status)) {
46
+ return { ...base, jobs: [localView(recorded)] };
47
+ }
48
+ if (!opts.client) {
49
+ // No client at all (no config or no key here). The receipt is all there is.
50
+ return recorded
51
+ ? { ...base, jobs: [localView(recorded)], offline: 'no API key or project config here' }
52
+ : { ...base, notFound: true, offline: 'no API key or project config here' };
53
+ }
54
+ try {
55
+ const job = await opts.client.getSyncJob(jobId);
56
+ if (recorded) {
57
+ persist(historyPath, [patchFor(job)]);
58
+ }
59
+ return { ...base, jobs: [{ id: jobId, status: job.status, source: 'server', history: recorded, job }] };
60
+ }
61
+ catch (err) {
62
+ // A 404 is an answer, not a failure: the id is unknown, or the job finished
63
+ // long enough ago that the server purged it (~1h retention). A job we
64
+ // already knew to be final never reaches here — it short-circuits above.
65
+ if (err instanceof ApiError && err.status === 404) {
66
+ if (recorded) {
67
+ persist(historyPath, [{ id: jobId, status: 'gone', checkedAt: new Date().toISOString() }]);
68
+ return { ...base, jobs: [{ ...localView({ ...recorded, status: 'gone' }), source: 'server' }] };
69
+ }
70
+ return { ...base, notFound: true };
71
+ }
72
+ if (!(err instanceof NetworkError))
73
+ throw err;
74
+ // Unreachable server: show the receipt, clearly marked as last-known.
75
+ if (!recorded)
76
+ return { ...base, notFound: true, offline: err.message };
77
+ return { ...base, jobs: [localView(recorded)], offline: err.message };
78
+ }
79
+ }
80
+ /** Bare `status` / `status list` — the local job log, refreshed where it matters. */
81
+ async function listJobs(history, historyPath, opts) {
82
+ const scoped = opts.server ? history.filter((e) => e.server === opts.server) : history;
83
+ if (scoped.length === 0) {
84
+ // Nothing local — ask the server what its queue looks like instead of
85
+ // shrugging. This is also the honest answer for "I pushed from another
86
+ // machine / another checkout".
87
+ return { mode: 'list', jobs: [], historyPath, ...(await queueFallback(opts.client)) };
88
+ }
89
+ const stale = scoped.filter((e) => !isTerminalJobStatus(e.status)).slice(0, MAX_REFRESH);
90
+ const views = new Map();
91
+ let offline;
92
+ if (opts.client && stale.length > 0) {
93
+ const client = opts.client;
94
+ const patches = [];
95
+ const refreshed = await mapLimit(stale, REFRESH_CONCURRENCY, async (entry) => {
96
+ try {
97
+ return await client.getSyncJob(entry.id);
98
+ }
99
+ catch (err) {
100
+ if (err instanceof ApiError && err.status === 404)
101
+ return 'gone';
102
+ if (err instanceof NetworkError) {
103
+ offline ??= err.message;
104
+ return undefined;
105
+ }
106
+ throw err;
107
+ }
108
+ });
109
+ refreshed.forEach((job, i) => {
110
+ const entry = stale[i];
111
+ if (job === undefined)
112
+ return;
113
+ if (job === 'gone') {
114
+ patches.push({ id: entry.id, status: 'gone', checkedAt: new Date().toISOString() });
115
+ views.set(entry.id, { id: entry.id, status: 'gone', source: 'server', history: entry });
116
+ return;
117
+ }
118
+ patches.push(patchFor(job));
119
+ views.set(entry.id, { id: entry.id, status: job.status, source: 'server', history: entry, job });
120
+ });
121
+ persist(historyPath, patches);
122
+ }
123
+ else if (!opts.client) {
124
+ offline = 'no API key or project config here';
125
+ }
126
+ const jobs = scoped.map((entry) => views.get(entry.id) ?? localView(entry));
127
+ return { mode: 'list', jobs, historyPath, ...(offline ? { offline } : {}) };
128
+ }
129
+ /** Queue-wide view for when this machine has no receipts to show. */
130
+ async function queueFallback(client) {
131
+ if (!client)
132
+ return { queueUnavailable: 'no API key or project config here' };
133
+ try {
134
+ const [queue, deadLettered] = await Promise.all([
135
+ client.getQueueHealth(),
136
+ client.getDeadLetterQueue(),
137
+ ]);
138
+ return { queue, deadLettered };
139
+ }
140
+ catch (err) {
141
+ if (err instanceof NetworkError)
142
+ return { offline: err.message };
143
+ // 403 is the common one: queue introspection needs the `ops:queue` scope,
144
+ // which publishable pk_ keys do not carry.
145
+ if (err instanceof ApiError) {
146
+ return {
147
+ queueUnavailable: err.status === 403
148
+ ? 'this API key may not read the queue (needs a master key)'
149
+ : `server returned ${err.status}`,
150
+ };
151
+ }
152
+ throw err;
153
+ }
154
+ }
155
+ function localView(entry) {
156
+ return {
157
+ id: entry.id,
158
+ status: entry.status,
159
+ source: isTerminalJobStatus(entry.status) ? 'local-final' : 'local',
160
+ history: entry,
161
+ };
162
+ }
163
+ function patchFor(job) {
164
+ return {
165
+ id: job.jobId,
166
+ status: job.status,
167
+ checkedAt: new Date().toISOString(),
168
+ error: job.error,
169
+ deadLetterReason: job.deadLetterReason,
170
+ };
171
+ }
172
+ /** History writes are best-effort — a read-only home must not fail `status`. */
173
+ function persist(historyPath, patches) {
174
+ if (patches.length > 0)
175
+ updateJobStatuses(patches, historyPath);
176
+ }
177
+ /**
178
+ * One-line verdict for a job state, in the terms the incident was about: were
179
+ * these rows stored or not. Shared by the detail and list renderings.
180
+ */
181
+ export function explainStatus(view) {
182
+ const dead = view.job?.isDeadLettered === true || view.status === 'failed';
183
+ switch (true) {
184
+ case dead:
185
+ return {
186
+ icon: '✖',
187
+ label: 'dead-lettered',
188
+ detail: 'the backend gave up — these rows were NOT stored',
189
+ };
190
+ case view.status === 'completed':
191
+ return { icon: '✓', label: 'completed', detail: storedDetail(view) };
192
+ case view.status === 'retry_pending':
193
+ return {
194
+ icon: '⚠',
195
+ label: 'retrying',
196
+ detail: 'a previous attempt failed; the backend will try again',
197
+ };
198
+ case view.status === 'processing':
199
+ return { icon: '·', label: 'processing', detail: 'the backend is storing these rows now' };
200
+ case view.status === 'pending':
201
+ return { icon: '·', label: 'queued', detail: 'accepted, not stored yet' };
202
+ case view.status === 'gone':
203
+ return {
204
+ icon: '?',
205
+ label: 'gone',
206
+ detail: 'the server no longer has this job — finished jobs are kept ~1h',
207
+ };
208
+ default:
209
+ return { icon: '?', label: view.status || 'unknown', detail: '' };
210
+ }
211
+ }
212
+ /** "128 key(s) stored (5 added, 3 updated)" when the server reported counts. */
213
+ function storedDetail(view) {
214
+ const result = view.job?.result;
215
+ const added = typeof result?.['keys_added'] === 'number' ? result['keys_added'] : undefined;
216
+ const updated = typeof result?.['keys_updated'] === 'number' ? result['keys_updated'] : undefined;
217
+ if (added === undefined && updated === undefined)
218
+ return 'the rows were stored';
219
+ return `stored — ${added ?? 0} added, ${updated ?? 0} updated`;
220
+ }
221
+ //# sourceMappingURL=status.js.map