fix(jobs): rehydrate wire-format timestamps in thin-client list/get (#3026) (#3027)

The thin-client branches receive MinionJob rows as parsed JSON off the
MCP wire — every timestamp an ISO string — while formatJob /
formatJobDetail and the stalled-detection comparison hold a Date
contract (locally hydrated by MinionQueue.rowToJob). `jobs get <id>` on
a thin client crashed with "job.started_at.toISOString is not a
function" the moment the remote routing actually worked (unmasked by
the #2951 scratch-engine fix).

Rehydrate once at the unpack boundary via an exported helper that
coerces valid ISO strings to Dates, leaves Dates/nulls/malformed
strings untouched, and preserves the input type. Unit tests +
source-audit pins for both unpack sites.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sailesh Sivakumar
2026-07-27 23:47:32 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 9664cad329
commit fd8be831c5
2 changed files with 105 additions and 2 deletions
+27 -2
View File
@@ -143,6 +143,31 @@ export function resolveWorkerConcurrency(args: string[], env: NodeJS.ProcessEnv
return parsed;
}
/**
* #3026: the thin-client `list`/`get` branches receive jobs as parsed JSON
* off the MCP wire, where every timestamp is an ISO string — but formatJob /
* formatJobDetail (and the stalled-detection comparison) hold a Date
* contract, hydrated locally by MinionQueue.rowToJob. Rehydrate once at the
* unpack boundary so both paths hand the formatters real Dates. Exported for
* unit tests.
*/
const JOB_DATE_FIELDS = [
'created_at', 'updated_at', 'started_at', 'finished_at', 'lock_until', 'delay_until',
] as const;
export function rehydrateJobDates<T>(job: T): T {
if (!job || typeof job !== 'object') return job;
const rec = job as { [k: string]: unknown };
for (const field of JOB_DATE_FIELDS) {
const v = rec[field];
if (typeof v === 'string') {
const d = new Date(v);
if (!Number.isNaN(d.getTime())) rec[field] = d;
}
}
return job;
}
function formatJob(job: MinionJob): string {
const dur = job.finished_at && job.started_at
? `${((job.finished_at.getTime() - job.started_at.getTime()) / 1000).toFixed(1)}s`
@@ -496,7 +521,7 @@ HANDLER TYPES (built in)
const raw = await callRemoteTool(cfg!, 'list_jobs', {
status, queue: queueName, limit,
}, { timeoutMs: 30_000 });
jobs = unpackToolResult<MinionJob[]>(raw);
jobs = unpackToolResult<MinionJob[]>(raw).map((j) => rehydrateJobDates(j));
} else {
try { await queue.ensureSchema(); }
catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); }
@@ -525,7 +550,7 @@ HANDLER TYPES (built in)
if (isThinClient(cfg)) {
try {
const raw = await callRemoteTool(cfg!, 'get_job', { id }, { timeoutMs: 30_000 });
job = unpackToolResult<MinionJob | null>(raw);
job = rehydrateJobDates(unpackToolResult<MinionJob | null>(raw));
} catch (e) {
// The remote op throws `invalid_params` on not-found; surface as
// the same "Job not found" exit-1 the local path produces.
@@ -0,0 +1,78 @@
/**
* #3026: thin-client `jobs list`/`get` receive MinionJob rows as parsed JSON
* off the MCP wire — every timestamp an ISO string — while formatJob /
* formatJobDetail and the stalled-detection comparison hold a Date contract
* (locally hydrated by MinionQueue.rowToJob). Before the fix, `jobs get <id>`
* on a thin client crashed with "job.started_at.toISOString is not a
* function" the moment the remote routing actually worked (unmasked by
* #2951's scratch-engine fix).
*
* Pins rehydrateJobDates (the unpack-boundary coercion) plus, audit-style,
* that both thin-client unpack sites route through it.
*/
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { rehydrateJobDates } from '../src/commands/jobs.ts';
describe('rehydrateJobDates', () => {
test('coerces wire-format ISO strings to Dates on all timestamp fields', () => {
const wire = {
id: 1192,
name: 'autopilot-cycle',
status: 'completed',
created_at: '2026-07-21T04:02:11.512Z',
updated_at: '2026-07-21T04:02:14.930Z',
started_at: '2026-07-21T04:02:12.001Z',
finished_at: '2026-07-21T04:02:14.900Z',
lock_until: '2026-07-21T04:03:12.001Z',
delay_until: null,
};
const job = rehydrateJobDates(wire);
expect(job.created_at).toBeInstanceOf(Date);
expect(job.updated_at).toBeInstanceOf(Date);
expect(job.started_at).toBeInstanceOf(Date);
expect(job.finished_at).toBeInstanceOf(Date);
expect(job.lock_until).toBeInstanceOf(Date);
expect((job.started_at as unknown as Date).toISOString()).toBe('2026-07-21T04:02:12.001Z');
// Date math used by formatJob's duration column works post-rehydration.
expect((job.finished_at as unknown as Date).getTime() - (job.started_at as unknown as Date).getTime())
.toBeCloseTo(2899, 0);
});
test('leaves Dates, nulls, and non-timestamp fields untouched', () => {
const started = new Date('2026-07-21T04:02:12.001Z');
const job = rehydrateJobDates({
id: 7,
name: 'sync',
status: 'active',
created_at: started,
started_at: started,
finished_at: null,
delay_until: undefined,
});
expect(job.created_at).toBe(started);
expect(job.finished_at).toBeNull();
expect(job.delay_until).toBeUndefined();
expect(job.name).toBe('sync');
});
test('does not fabricate Dates from malformed strings; passes null through', () => {
const job = rehydrateJobDates({ id: 8, started_at: 'not-a-date' });
expect(job.started_at).toBe('not-a-date');
expect(rehydrateJobDates(null)).toBeNull();
});
});
describe('thin-client unpack sites route through rehydrateJobDates (source audit)', () => {
const src = readFileSync(join(import.meta.dir, '..', 'src', 'commands', 'jobs.ts'), 'utf8');
test('list branch rehydrates', () => {
expect(src).toContain('unpackToolResult<MinionJob[]>(raw).map((j) => rehydrateJobDates(j))');
});
test('get branch rehydrates', () => {
expect(src).toContain('rehydrateJobDates(unpackToolResult<MinionJob | null>(raw))');
});
});