Skip to content

Commit d6ad9a5

Browse files
author
bgagent
committed
fix(types): tighten TaskDetail numeric coercion + ChannelSource literal union (krokoko review #2, #4, #10)
Three related type-safety fixes from the code review on PR #52. Grouped because they all touch the same contract surface (``TaskDetail`` on both sides of the wire) and share the theme "honest types at the DDB + API boundary". ## Findings addressed **#2 — turns_attempted / turns_completed bypass coerceNumericOrNull** ``toTaskDetail`` at ``cdk/src/handlers/shared/types.ts`` was passing ``record.turns_attempted`` and ``record.turns_completed`` through with only ``?? null`` — same bug class as the ``costUsd.toFixed is not a function`` crash fixed at the fanout boundary in commit ``9fe704e`` / consolidated in ``c09bfd7``. The DDB Document-client deserializes ``Number`` attributes as ``string`` on some code paths; any downstream caller doing arithmetic on these fields would crash at runtime. Fix extends beyond the two fields the review called out — ALL numeric fields sourced from the DDB record now route through ``coerceNumericOrNull``: ``duration_s``, ``cost_usd``, ``max_turns``, ``max_budget_usd``, ``turns_attempted``, ``turns_completed``. A new JSDoc block on ``toTaskDetail`` documents the contract ("all numeric fields coerced through shared helper; do not bypass") so a future field addition has a clear pattern to follow. Scope-bounded: the non-numeric fields (``task_description``, ``pr_url``, …) and request-body-validated ints (``issue_number``, ``pr_number``) stay untouched. **#4 — CLI/CDK type drift on turns_attempted / turns_completed** Per AGENTS.md the CDK and CLI ``TaskDetail`` declarations must stay in sync. CDK declared both fields as required (``number | null``); CLI marked them optional (``number | null | undefined``). The tightening means the server's guarantee (``toTaskDetail`` always emits both fields, defaulting to ``null`` when absent on the record) now flows honestly to the CLI type. **#10 — channel_source typed as string instead of literal union** Added an exported ``ChannelSource = 'api' | 'webhook'`` literal union in both CDK and CLI ``types.ts``. ``TaskRecord.channel_source`` and ``TaskDetail.channel_source`` on both sides now use the narrow type. Downstream switches/predicates that read ``channel_source`` get exhaustiveness checking at compile time, matching the internal ``CreateTaskContext.channelSource`` literal already in use at ``create-task-core.ts``. Reviewer's comment: "the internal CreateTaskContext correctly narrows it but the external types don't" — now they do. ## Tests +3 regression tests in ``cdk/test/handlers/shared/error-classifier.test.ts`` under the ``toTaskDetail integration`` block: - String-typed DDB numeric fields coerce to finite numbers on the ``TaskDetail`` output (``typeof === 'number'`` + exact value for all six coerced fields). - Unparseable numeric strings collapse to ``null`` without crash (defence test for the non-finite branch in ``coerceNumericOrNull``). - ``channel_source`` narrows to the literal union — uses ``@ts-expect-error`` to pin that a widened ``'slack'`` fails to compile, so a future ``ChannelSource`` regression surfaces in CI immediately. CLI test fixtures updated to include ``channel_source: 'api'`` and ``turns_attempted: null`` / ``turns_completed: null`` where the non-optional fields were previously omitted. No CLI test count change — the fixture additions satisfy the stricter contract without requiring new test bodies. CDK suite: 1032 passing (was 1029). CLI suite: 190 passing. Refs: krokoko code review on PR #52 (findings 2, 4, 10)
1 parent 1b8ffa6 commit d6ad9a5

5 files changed

Lines changed: 118 additions & 18 deletions

File tree

cdk/src/handlers/shared/types.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,25 @@
1818
*/
1919

2020
import { classifyError, type ErrorClassification } from './error-classifier';
21+
import { logger } from './logger';
22+
import { coerceNumericOrNull } from './numeric';
2123
import type { ComputeType } from './repo-config';
2224
import type { TaskStatusType } from '../../constructs/task-status';
2325

2426
/** Valid task types for task creation. */
2527
export type TaskType = 'new_task' | 'pr_iteration' | 'pr_review';
2628

29+
/**
30+
* Provenance of a task's submission. ``api`` covers CLI / Cognito-authenticated
31+
* submissions; ``webhook`` covers HMAC-signed inbound webhook submissions.
32+
*
33+
* Narrowed from ``string`` so switches and predicates that read
34+
* ``channel_source`` get exhaustiveness checking at compile time; matches the
35+
* internal ``CreateTaskContext.channelSource`` literal in ``create-task-core.ts``.
36+
* Keep in sync with ``cli/src/types.ts::ChannelSource``.
37+
*/
38+
export type ChannelSource = 'api' | 'webhook';
39+
2740
/** Task types that operate on an existing pull request. */
2841
export function isPrTaskType(taskType: TaskType): boolean {
2942
return taskType === 'pr_iteration' || taskType === 'pr_review';
@@ -51,7 +64,7 @@ export interface TaskRecord {
5164
readonly pr_url?: string;
5265
readonly error_message?: string;
5366
readonly idempotency_key?: string;
54-
readonly channel_source: string;
67+
readonly channel_source: ChannelSource;
5568
readonly channel_metadata?: Record<string, string>;
5669
readonly status_created_at: string;
5770
readonly created_at: string;
@@ -155,7 +168,7 @@ export interface TaskDetail {
155168
* on every task record at creation time (``create-task-core.ts``)
156169
* and surfaced here so CLI / dashboard / audit consumers do not have
157170
* to spelunk CloudWatch to learn which channel created a task. */
158-
readonly channel_source: string;
171+
readonly channel_source: ChannelSource;
159172
readonly created_at: string;
160173
readonly updated_at: string;
161174
readonly started_at: string | null;
@@ -274,10 +287,21 @@ export interface Attachment {
274287

275288
/**
276289
* Map a DynamoDB task record to the API detail response shape.
290+
*
291+
* All numeric fields sourced from the DDB record are routed through
292+
* ``coerceNumericOrNull`` — the Document-client deserializes DynamoDB
293+
* ``Number`` attributes as JavaScript ``string``s in some code paths
294+
* (see ``shared/numeric.ts`` for rationale), and any downstream caller
295+
* doing arithmetic (``.toFixed``, comparison, math) on a string-typed
296+
* "number" crashes at runtime. Coercing uniformly here means no caller
297+
* has to guess which TaskDetail numeric fields are safe — do not bypass
298+
* the helper when adding new numeric fields.
299+
*
277300
* @param record - the DynamoDB task record.
278301
* @returns the API-facing task detail.
279302
*/
280303
export function toTaskDetail(record: TaskRecord): TaskDetail {
304+
const ctx = { task_id: record.task_id };
281305
return {
282306
task_id: record.task_id,
283307
status: record.status,
@@ -296,13 +320,13 @@ export function toTaskDetail(record: TaskRecord): TaskDetail {
296320
updated_at: record.updated_at,
297321
started_at: record.started_at ?? null,
298322
completed_at: record.completed_at ?? null,
299-
duration_s: record.duration_s ?? null,
300-
cost_usd: record.cost_usd ?? null,
323+
duration_s: coerceNumericOrNull(record.duration_s, { ...ctx, field: 'duration_s' }, logger),
324+
cost_usd: coerceNumericOrNull(record.cost_usd, { ...ctx, field: 'cost_usd' }, logger),
301325
build_passed: record.build_passed ?? null,
302-
max_turns: record.max_turns ?? null,
303-
max_budget_usd: record.max_budget_usd ?? null,
304-
turns_attempted: record.turns_attempted ?? null,
305-
turns_completed: record.turns_completed ?? null,
326+
max_turns: coerceNumericOrNull(record.max_turns, { ...ctx, field: 'max_turns' }, logger),
327+
max_budget_usd: coerceNumericOrNull(record.max_budget_usd, { ...ctx, field: 'max_budget_usd' }, logger),
328+
turns_attempted: coerceNumericOrNull(record.turns_attempted, { ...ctx, field: 'turns_attempted' }, logger),
329+
turns_completed: coerceNumericOrNull(record.turns_completed, { ...ctx, field: 'turns_completed' }, logger),
306330
prompt_version: record.prompt_version ?? null,
307331
trace: record.trace === true,
308332
trace_s3_uri: record.trace_s3_uri ?? null,

cdk/test/handlers/shared/error-classifier.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ describe('classifyError', () => {
420420
repo: 'owner/repo',
421421
task_type: 'new_task',
422422
branch_name: 'bgagent/task-1/fix',
423-
channel_source: 'cli',
423+
channel_source: 'api',
424424
status_created_at: 'FAILED#2026-01-01T00:00:00Z',
425425
created_at: '2026-01-01T00:00:00Z',
426426
updated_at: '2026-01-01T00:00:00Z',
@@ -446,5 +446,65 @@ describe('classifyError', () => {
446446
expect(detail.error_classification).not.toBeNull();
447447
expect(detail.error_classification!.category).toBe('unknown');
448448
});
449+
450+
// Regression: all numeric fields coerce through ``coerceNumericOrNull``
451+
// so the DDB Document-client's string-typed Number deserialization
452+
// cannot leak into downstream consumers (same bug class as the
453+
// ``costUsd.toFixed`` crash fixed in commit ``c09bfd7``). The cast
454+
// to ``unknown as TaskRecord`` simulates a record produced by the
455+
// Document client where ``Number`` attributes came back as strings.
456+
test('coerces string-typed numeric DDB fields to numbers on output', () => {
457+
const record = {
458+
...baseRecord,
459+
duration_s: '12.5',
460+
cost_usd: '0.0042',
461+
max_turns: '30',
462+
max_budget_usd: '1.50',
463+
turns_attempted: '7',
464+
turns_completed: '6',
465+
} as unknown as TaskRecord;
466+
const detail = toTaskDetail(record);
467+
expect(typeof detail.duration_s).toBe('number');
468+
expect(detail.duration_s).toBe(12.5);
469+
expect(typeof detail.cost_usd).toBe('number');
470+
expect(detail.cost_usd).toBe(0.0042);
471+
expect(typeof detail.max_turns).toBe('number');
472+
expect(detail.max_turns).toBe(30);
473+
expect(typeof detail.max_budget_usd).toBe('number');
474+
expect(detail.max_budget_usd).toBe(1.5);
475+
expect(typeof detail.turns_attempted).toBe('number');
476+
expect(detail.turns_attempted).toBe(7);
477+
expect(typeof detail.turns_completed).toBe('number');
478+
expect(detail.turns_completed).toBe(6);
479+
});
480+
481+
test('coerces unparseable numeric strings to null (does not crash)', () => {
482+
const record = {
483+
...baseRecord,
484+
turns_attempted: 'not-a-number',
485+
turns_completed: 'NaN',
486+
} as unknown as TaskRecord;
487+
const detail = toTaskDetail(record);
488+
expect(detail.turns_attempted).toBeNull();
489+
expect(detail.turns_completed).toBeNull();
490+
});
491+
492+
// Compile-time regression for Finding #10 — ``ChannelSource`` is a
493+
// literal union, not ``string``. The ``satisfies`` assertions below
494+
// exercise the valid members; the ``@ts-expect-error`` comments pin
495+
// the narrowing — if someone widens ``ChannelSource`` to ``string``
496+
// these will become un-erroring and fail the build.
497+
test('channel_source narrows to the literal union', () => {
498+
const apiRecord: TaskRecord = { ...baseRecord, channel_source: 'api' };
499+
const webhookRecord: TaskRecord = { ...baseRecord, channel_source: 'webhook' };
500+
expect(toTaskDetail(apiRecord).channel_source).toBe('api');
501+
expect(toTaskDetail(webhookRecord).channel_source).toBe('webhook');
502+
503+
// @ts-expect-error — 'slack' is not a valid ChannelSource
504+
const invalid: TaskRecord = { ...baseRecord, channel_source: 'slack' };
505+
// Keep ``invalid`` used so the block doesn't get DCE'd and the
506+
// ``@ts-expect-error`` above remains anchored to a real assignment.
507+
expect(invalid.channel_source).toBeDefined();
508+
});
449509
});
450510
});

cli/src/types.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@
2020
/** Valid task types for task creation. */
2121
export type TaskType = 'new_task' | 'pr_iteration' | 'pr_review';
2222

23+
/**
24+
* Provenance of a task's submission. ``api`` covers CLI / Cognito-authenticated
25+
* submissions; ``webhook`` covers HMAC-signed inbound webhook submissions.
26+
* Mirrors ``cdk/src/handlers/shared/types.ts::ChannelSource`` per the CLI
27+
* types-sync contract so downstream switches/predicates get exhaustiveness
28+
* checking on both sides of the wire.
29+
*/
30+
export type ChannelSource = 'api' | 'webhook';
31+
2332
/** Error categories produced by the runtime error classifier. */
2433
export type ErrorCategoryType = 'auth' | 'network' | 'concurrency' | 'compute' | 'agent' | 'guardrail' | 'config' | 'timeout' | 'unknown';
2534

@@ -50,7 +59,7 @@ export interface TaskDetail {
5059
* submissions, ``webhook`` for HMAC-signed inbound webhooks.
5160
* Mirrors ``cdk/src/handlers/shared/types.ts::TaskDetail``; kept
5261
* in sync per the CLI types-sync contract. */
53-
readonly channel_source: string;
62+
readonly channel_source: ChannelSource;
5463
readonly created_at: string;
5564
readonly updated_at: string;
5665
readonly started_at: string | null;
@@ -62,11 +71,14 @@ export interface TaskDetail {
6271
readonly max_budget_usd: number | null;
6372
/** Rev-5 DATA-1: attempts counter from the SDK (may be `max_turns + 1`
6473
* when `agent_status='error_max_turns'` — the aborted attempt is
65-
* counted). */
66-
readonly turns_attempted?: number | null;
74+
* counted). Required to match ``cdk/src/handlers/shared/types.ts``
75+
* (server always emits the field, defaulted to ``null`` in
76+
* ``toTaskDetail`` when absent on the record). */
77+
readonly turns_attempted: number | null;
6778
/** Rev-5 DATA-1: turns that actually completed (clamped to
68-
* `max_turns` when the cap tripped). */
69-
readonly turns_completed?: number | null;
79+
* `max_turns` when the cap tripped). Required; see
80+
* ``turns_attempted`` above. */
81+
readonly turns_completed: number | null;
7082
/** Whether the task was submitted with ``--trace``. Surfaces in
7183
* ``bgagent status --output json`` so scripts can confirm trace
7284
* capture is active. Non-optional because the server always

cli/test/format-status-snapshot.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
*/
1919

2020
import { formatStatusSnapshot } from '../src/format';
21-
import { TaskDetail, TaskEvent } from '../src/types';
21+
import { ChannelSource, TaskDetail, TaskEvent } from '../src/types';
2222

2323
const NOW = Date.parse('2026-04-29T15:30:20Z');
2424

@@ -447,9 +447,11 @@ describe('formatStatusSnapshot', () => {
447447
});
448448

449449
test('Channel line is always present (even when channel_source is an unexpected value)', () => {
450-
// Degrades to the placeholder rather than omitting the line —
451-
// consistent with other always-present snapshot rows.
452-
const task = buildTask({ channel_source: '' as unknown as string });
450+
// Defence-in-depth: even though ``ChannelSource`` narrows the type to
451+
// ``api | webhook``, a corrupt DDB record could still arrive at the
452+
// formatter. The snapshot degrades to the placeholder rather than
453+
// omitting the line — consistent with other always-present rows.
454+
const task = buildTask({ channel_source: '' as unknown as ChannelSource });
453455
const rendered = formatStatusSnapshot(task, [], NOW);
454456
expect(rendered).toContain('Channel: —');
455457
});

cli/test/format.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ describe('format', () => {
4444
build_passed: true,
4545
max_turns: 100,
4646
max_budget_usd: null,
47+
turns_attempted: null,
48+
turns_completed: null,
4749
trace: false,
4850
trace_s3_uri: null,
4951
};

0 commit comments

Comments
 (0)