mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
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:
co-authored by
Claude Fable 5
parent
9664cad329
commit
fd8be831c5
+27
-2
@@ -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))');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user