Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/lib/server/create-adcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2729,6 +2729,7 @@ function toProtocolTaskStatus(task: TaskRecord): GetTaskStatusResponse | undefin
? { completed_at: task.updatedAt }
: {}),
...(task.hasWebhook !== undefined ? { has_webhook: task.hasWebhook === true } : {}),
...(task.ext !== undefined ? { ext: task.ext } : {}),
...(progress !== undefined ? { progress } : {}),
...(task.status === 'rejected' && task.statusMessage !== undefined ? { message: task.statusMessage } : {}),
...(task.error !== undefined
Expand Down Expand Up @@ -2762,6 +2763,8 @@ function toProtocolTaskListItem(task: TaskRecord): ListTasksResponse['tasks'][nu
? { completed_at: task.updatedAt }
: {}),
...(task.hasWebhook !== undefined ? { has_webhook: task.hasWebhook === true } : {}),
// The list item schema is open (passthrough); `ext` is the spec's vendor slot.
...(task.ext !== undefined ? ({ ext: task.ext } as Record<string, unknown>) : {}),
};
}

Expand Down
8 changes: 8 additions & 0 deletions src/lib/server/decisioning/async-outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,14 @@ export interface TaskHandoffOptions {
* The caller is responsible for uniqueness within the account.
*/
task_id?: string;
/**
* Vendor-namespaced extension object echoed on the submitted envelope
* (`{ status: 'submitted', task_id, ext }`). The spec's submitted branch
* declares `ext`; keys MUST be namespaced under a vendor key (`ext.acme`),
* never a spec field such as `media_buy_id` — those belong on the terminal
* artifact. Not persisted; a registry decorator may re-attach it on reads.
*/
ext?: Record<string, unknown>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MUST FIX: No changeset in this PR. It touches src/lib/** (non-generated) and adds new public API surface — TaskHandoffOptions.ext here, TaskRecord.ext in task-registry.ts, plus new ext projection on get_task_status/tasks_get/list_tasks. Per the repo's changeset-vs-wire-impact rule, a missing .changeset/*.md on a src/lib/** change is a blocking finding, and without it the feature never ships to npm. Additive new optional fields → minor. Run npm run changeset.

}

/**
Expand Down
15 changes: 11 additions & 4 deletions src/lib/server/decisioning/runtime/from-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4089,6 +4089,7 @@ function buildTasksGetTool<P extends DecisioningPlatform<any, any>>(
logger.warn?.('Omitting unsafe stored task progress during tasks_get poll');
}
}
if (record.ext !== undefined) payload.ext = record.ext;
if (
args.include_result === true &&
(record.status === 'completed' || record.status === 'failed' || record.status === 'rejected') &&
Expand Down Expand Up @@ -4441,6 +4442,8 @@ function buildDefaultTaskRegistry(): TaskRegistry {
type SubmittedEnvelope = {
status: 'submitted';
task_id: string;
/** Adopter-supplied, vendor-namespaced extension (`TaskHandoffOptions.ext`). */
ext?: Record<string, unknown>;
};

/**
Expand Down Expand Up @@ -4812,7 +4815,8 @@ async function routeIfHandoff<TInner, TWire>(
await externalTaskFn(buildExternalHandoffContext(taskRegistry, taskRef, opts.servedAdcpVersion));
},
options.task_id,
'external'
'external',
options.ext
);
}
let handoffTaskStarted = false;
Expand Down Expand Up @@ -4840,7 +4844,9 @@ async function routeIfHandoff<TInner, TWire>(
await lifecycle?.onHandoffSuccess?.(inner);
return await project(inner);
},
options?.task_id
options?.task_id,
'framework',
options?.ext
);
} catch (error) {
// Allocation/registration failures happen before the background task
Expand All @@ -4860,7 +4866,8 @@ async function dispatchHitl<TResult>(
opts: DispatchHitlOpts,
taskFn: (taskRef: ScopedTaskRef) => Promise<TResult>,
overrideTaskId?: string,
settlement: 'framework' | 'external' = 'framework'
settlement: 'framework' | 'external' = 'framework',
ext?: Record<string, unknown>
): Promise<SubmittedEnvelope> {
// Fail before task creation, external producer callbacks, or any terminal
// state can be persisted. A buyer gets Submitted only after a validated
Expand Down Expand Up @@ -5088,7 +5095,7 @@ async function dispatchHitl<TResult>(
})();
taskRegistry._registerBackground(taskId, taskRef, completion);

return { status: 'submitted', task_id: taskId };
return { status: 'submitted', task_id: taskId, ...(ext !== undefined && { ext }) };
}

/**
Expand Down
7 changes: 7 additions & 0 deletions src/lib/server/decisioning/runtime/task-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@ export interface TaskRecord<TResult = unknown, TError extends AdcpStructuredErro
* `tasks_get` via the spec `progress` field.
*/
progress?: TaskHandoffProgress;
/**
* Vendor-namespaced extension object projected as `ext` on task reads
* (`get_task_status`, `tasks_get`, `list_tasks` items). The built-in
* registries never persist it; a decorating registry attaches it at read
* time (e.g. the vendor-side object a task holds).
*/
ext?: Record<string, unknown>;
/**
* Whether the buyer wired `push_notification_config.url` and the dispatch
* had an emitter capable of delivering it. Surfaced to the buyer via
Expand Down
33 changes: 33 additions & 0 deletions test/server-create-adcp-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2989,6 +2989,39 @@ describe('createAdcpServer', () => {
assert.strictEqual(listed.structuredContent.adcp_error.code, 'VALIDATION_ERROR');
});

it('projects a decorating registry ext onto get_task_status and list_tasks items', async () => {
const inner = createInMemoryTaskRegistry();
const held = await inner.create({ tool: 'create_media_buy', accountId: 'acct_1', ownerScope: 'api_key:buyer-1' });
const ext = { acme: { media_buy_id: 'mb_42', campaign_link: 'https://acme.example/c/42' } };
// A vendor decorator attaches the extension at read time; the built-in registry never stores it.
const taskRegistry = {
...inner,
getTask: async (taskId, scope) => {
const record = await inner.getTask(taskId, scope);
return record && record.taskId === held.taskId ? { ...record, ext } : record;
},
list: async opts => {
const listed = await inner.list(opts);
return { ...listed, tasks: listed.tasks.map(t => (t.taskId === held.taskId ? { ...t, ext } : t)) };
},
};
const server = createAdcpServer({
name: 'Test',
version: '1.0.0',
taskRegistry,
resolveAccountFromAuth: async () => ({ id: 'acct_1' }),
});
const buyerOne = { authInfo: { credential: { kind: 'api_key', key_id: 'buyer-1' } } };

const status = await callTool(server, 'get_task_status', { task_id: held.taskId }, buyerOne);
assert.strictEqual(status.status, 'submitted');
assert.deepStrictEqual(status.ext, ext);

const listed = await callTool(server, 'list_tasks', {}, buyerOne);
assert.strictEqual(listed.tasks.length, 1);
assert.deepStrictEqual(listed.tasks[0].ext, ext);
});

it('answers get_task_status/list_tasks from the scoped AdCP task registry only', async () => {
const taskRegistry = createInMemoryTaskRegistry();
const owned = await taskRegistry.create({
Expand Down
65 changes: 46 additions & 19 deletions test/server-decisioning-mock-seller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,27 +119,30 @@ function makeSyncMockSeller({ floorCpm = 1.0 } = {}) {
return platform;
}

function makeHitlMockSeller({ floorCpm = 1.0, approvalDurationMs = 30 } = {}) {
function makeHitlMockSeller({ floorCpm = 1.0, approvalDurationMs = 30, ext } = {}) {
const platform = basePlatformShape({
createMediaBuy: (req, ctx) =>
ctx.handoffToTask(async () => {
const errors = preflight(req, { floorCpm });
if (errors.length > 0) {
throw new AdcpError('INVALID_REQUEST', {
recovery: 'correctable',
message: errors[0].message,
field: errors[0].field,
details: { errors },
});
}
// Trafficker review window
await new Promise(r => setTimeout(r, approvalDurationMs));
const buyId = `mb_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const totalBudget = typeof req.total_budget === 'number' ? req.total_budget : (req.total_budget?.amount ?? 0);
const buy = { media_buy_id: buyId, status: 'active', total_budget: totalBudget };
platform.mediaBuys.set(buyId, buy);
return buy;
}),
ctx.handoffToTask(
async () => {
const errors = preflight(req, { floorCpm });
if (errors.length > 0) {
throw new AdcpError('INVALID_REQUEST', {
recovery: 'correctable',
message: errors[0].message,
field: errors[0].field,
details: { errors },
});
}
// Trafficker review window
await new Promise(r => setTimeout(r, approvalDurationMs));
const buyId = `mb_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const totalBudget = typeof req.total_budget === 'number' ? req.total_budget : (req.total_budget?.amount ?? 0);
const buy = { media_buy_id: buyId, status: 'active', total_budget: totalBudget };
platform.mediaBuys.set(buyId, buy);
return buy;
},
ext !== undefined ? { ext } : undefined
),
});
return platform;
}
Expand Down Expand Up @@ -226,6 +229,30 @@ describe('MockSeller worked example — unified hybrid shape', () => {
assert.strictEqual(final.result.status, 'active');
});

it('echoes the adopter ext on the submitted envelope, vendor-namespaced', async () => {
const ext = { acme: { media_buy_id: 'mb_pending_1', campaign_link: 'https://acme.example/c/1' } };
const platform = makeHitlMockSeller({ approvalDurationMs: 30, ext });
const server = buildServer(platform);
const result = await dispatchCreate(server, { total_budget: 100_000 });

assert.strictEqual(result.structuredContent.status, 'submitted');
assert.deepStrictEqual(result.structuredContent.ext, ext);
assert.strictEqual(result.structuredContent.media_buy_id, undefined);

await server.awaitTaskUnsafe(result.structuredContent.task_id);
});

it('omits ext from the submitted envelope when the adopter passed none', async () => {
const platform = makeHitlMockSeller({ approvalDurationMs: 30 });
const server = buildServer(platform);
const result = await dispatchCreate(server, { total_budget: 100_000 });

assert.strictEqual(result.structuredContent.status, 'submitted');
assert.ok(!('ext' in result.structuredContent));

await server.awaitTaskUnsafe(result.structuredContent.task_id);
});

it('background AdcpError records terminal failed with structured fields', async () => {
const platform = makeHitlMockSeller();
const server = buildServer(platform);
Expand Down
Loading