blob: 3050452d9aee998332810d49ffb2d3e1b78c7602 [file]
import {
TASK_EVIDENCE_MAX_CHARS,
TASK_SUBJECT_MAX_CHARS,
isResumeTrust,
isSafeTaskId,
isTaskKey,
isTaskOwner,
isTaskStatus,
normalizeTaskEvidenceText,
normalizeTaskSubject,
sanitizeTaskLedgerTask,
type Task,
type TaskOwner,
validateTaskEvidence,
} from '@maka/core';
import { requireEntityId, requireExactRecord, requireRecord } from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import { defineOperation } from './operation-spec.js';
export const TASK_LEDGER_PAGE_MAX_ITEMS = 128;
export const TASK_LEDGER_PAGE_MAX_BYTES = 48 * 1024;
export const TASK_LEDGER_CURSOR_MAX_BYTES = 512;
const TASK_REQUIRED_FIELDS = ['id', 'key', 'subject', 'status', 'createdAt', 'updatedAt'] as const;
const TASK_FIELDS = [
...TASK_REQUIRED_FIELDS,
'parentId',
'owner',
'endedAt',
'blockedReason',
'failureReason',
'completionEvidence',
'resumeTrust',
] as const;
const TASK_OWNER_FIELDS = ['actor', 'sessionId', 'agentId', 'runId', 'turnId'] as const;
const REDACTED_TASK_TEXT = '[redacted]';
const QUERY_ERRORS = [
'host_not_ready',
'host_draining',
'operation_unavailable',
'invalid_request',
'not_found',
'internal_failure',
] as const;
export type TaskLedgerRevision = `sha256:${string}`;
export type TaskLedgerTask = Readonly<Task>;
export type TaskLedgerQueryInput =
| { readonly kind: 'list_start'; readonly sessionId: string }
| {
readonly kind: 'list_continue';
readonly sessionId: string;
readonly revision: TaskLedgerRevision;
readonly cursor: string;
}
| { readonly kind: 'get'; readonly sessionId: string; readonly taskRef: string };
export type TaskLedgerQueryResult =
| {
readonly kind: 'page';
readonly sessionId: string;
readonly revision: TaskLedgerRevision;
readonly tasks: readonly TaskLedgerTask[];
readonly nextCursor: string | null;
}
| {
readonly kind: 'revision_changed';
readonly expected: TaskLedgerRevision;
readonly actual: TaskLedgerRevision;
}
| {
readonly kind: 'task';
readonly sessionId: string;
readonly revision: TaskLedgerRevision;
readonly task: TaskLedgerTask | null;
};
export const TASK_LEDGER_OPERATION_SPECS = {
'task.ledger.query': defineOperation<
TaskLedgerQueryInput,
TaskLedgerQueryResult,
(typeof QUERY_ERRORS)[number]
>({
mode: 'query',
availability: 'ready',
errors: QUERY_ERRORS,
decodeInput: decodeTaskLedgerQueryInput,
decodeOutput: decodeTaskLedgerQueryResult,
}),
} as const;
export function decodeTaskLedgerQueryInput(value: unknown): TaskLedgerQueryInput {
const record = requireRecord(value, 'task ledger query input');
if (record.kind === 'list_start') {
const input = requireExactRecord(record, 'task ledger list start input', ['kind', 'sessionId']);
return { kind: 'list_start', sessionId: requireEntityId(input.sessionId, 'sessionId') };
}
if (record.kind === 'list_continue') {
const input = requireExactRecord(record, 'task ledger list continuation input', [
'kind',
'sessionId',
'revision',
'cursor',
]);
return {
kind: 'list_continue',
sessionId: requireEntityId(input.sessionId, 'sessionId'),
revision: taskLedgerRevision(input.revision, 'task ledger revision'),
cursor: taskLedgerCursor(input.cursor, 'task ledger cursor'),
};
}
if (record.kind === 'get') {
const input = requireExactRecord(record, 'task ledger get input', [
'kind',
'sessionId',
'taskRef',
]);
return {
kind: 'get',
sessionId: requireEntityId(input.sessionId, 'sessionId'),
taskRef: taskReference(input.taskRef),
};
}
throw invalidProtocolFrame('Invalid task ledger query kind');
}
export function decodeTaskLedgerQueryResult(value: unknown): TaskLedgerQueryResult {
return taskLedgerQueryResult(value, 'decode');
}
export function encodeTaskLedgerQueryResult(value: unknown): TaskLedgerQueryResult {
return taskLedgerQueryResult(value, 'encode');
}
export function encodeTaskLedgerTask(value: unknown): TaskLedgerTask {
return taskLedgerTask(value, 'encode');
}
function taskLedgerQueryResult(
value: unknown,
direction: 'encode' | 'decode',
): TaskLedgerQueryResult {
const record = requireRecord(value, 'task ledger query result');
if (record.kind === 'revision_changed') {
const changed = requireExactRecord(record, 'task ledger revision changed result', [
'kind',
'expected',
'actual',
]);
return {
kind: 'revision_changed',
expected: taskLedgerRevision(changed.expected, 'expected task ledger revision'),
actual: taskLedgerRevision(changed.actual, 'actual task ledger revision'),
};
}
if (record.kind === 'task') {
const result = requireExactRecord(record, 'task ledger task result', [
'kind',
'sessionId',
'revision',
'task',
]);
return {
kind: 'task',
sessionId: requireEntityId(result.sessionId, 'sessionId'),
revision: taskLedgerRevision(result.revision, 'task ledger revision'),
task: result.task === null ? null : taskLedgerTask(result.task, direction),
};
}
if (record.kind !== 'page') throw invalidProtocolFrame('Invalid task ledger query result kind');
const page = requireExactRecord(record, 'task ledger page result', [
'kind',
'sessionId',
'revision',
'tasks',
'nextCursor',
]);
if (!Array.isArray(page.tasks) || page.tasks.length > TASK_LEDGER_PAGE_MAX_ITEMS) {
throw invalidProtocolFrame('Task ledger page exceeds item limit');
}
const decoded: TaskLedgerQueryResult = {
kind: 'page',
sessionId: requireEntityId(page.sessionId, 'sessionId'),
revision: taskLedgerRevision(page.revision, 'task ledger revision'),
tasks: page.tasks.map((task) => taskLedgerTask(task, direction)),
nextCursor:
page.nextCursor === null
? null
: taskLedgerCursor(page.nextCursor, 'task ledger next cursor'),
};
if (jsonByteLength(decoded) > TASK_LEDGER_PAGE_MAX_BYTES) {
throw invalidProtocolFrame('Task ledger page exceeds byte limit');
}
return decoded;
}
function taskLedgerTask(value: unknown, direction: 'encode' | 'decode'): TaskLedgerTask {
const record = requireRecord(value, 'task ledger task');
assertAllowedKeys(record, 'task ledger task', TASK_FIELDS);
if (TASK_REQUIRED_FIELDS.some((field) => !Object.hasOwn(record, field))) {
throw invalidProtocolFrame('Invalid task ledger task fields');
}
const task: Task = {
id: stableTaskId(record.id, 'task id'),
key: taskKey(record.key),
subject: boundedTaskText(record.subject, 'subject'),
status: taskStatus(record.status),
createdAt: timestamp(record.createdAt, 'task createdAt'),
updatedAt: timestamp(record.updatedAt, 'task updatedAt'),
...optionalStableTaskId(record, 'parentId'),
...optionalOwner(record),
...optionalTimestamp(record, 'endedAt'),
...optionalTaskText(record, 'blockedReason'),
...optionalTaskText(record, 'failureReason'),
...optionalTaskText(record, 'completionEvidence'),
...optionalResumeTrust(record),
};
const projected = projectTaskForWire(task);
if (direction === 'decode' && !hasCanonicalWireProjection(task, projected)) {
throw invalidProtocolFrame('Task ledger task is not sanitized');
}
return projected;
}
function optionalStableTaskId(
record: Record<string, unknown>,
field: 'parentId',
): Pick<Task, 'parentId'> | Record<string, never> {
return Object.hasOwn(record, field)
? { [field]: stableTaskId(record[field], `task ${field}`) }
: {};
}
function optionalOwner(
record: Record<string, unknown>,
): Pick<Task, 'owner'> | Record<string, never> {
if (!Object.hasOwn(record, 'owner')) return {};
return { owner: taskOwner(record.owner) };
}
function taskOwner(value: unknown): TaskOwner {
const record = requireRecord(value, 'task owner');
assertAllowedKeys(record, 'task owner', TASK_OWNER_FIELDS);
if (!Object.hasOwn(record, 'actor') || !isTaskOwner(record)) {
throw invalidProtocolFrame('Invalid task owner');
}
return {
actor: record.actor as TaskOwner['actor'],
...optionalOwnerId(record, 'sessionId'),
...optionalOwnerId(record, 'agentId'),
...optionalOwnerId(record, 'runId'),
...optionalOwnerId(record, 'turnId'),
};
}
function optionalOwnerId<Field extends Exclude<keyof TaskOwner, 'actor'>>(
record: Record<string, unknown>,
field: Field,
): Pick<TaskOwner, Field> | Record<string, never> {
return Object.hasOwn(record, field)
? ({ [field]: stableTaskId(record[field], `task owner ${field}`) } as Pick<TaskOwner, Field>)
: {};
}
function optionalTimestamp(
record: Record<string, unknown>,
field: 'endedAt',
): Pick<Task, 'endedAt'> | Record<string, never> {
return Object.hasOwn(record, field) ? { [field]: timestamp(record[field], `task ${field}`) } : {};
}
function optionalTaskText<Field extends 'blockedReason' | 'failureReason' | 'completionEvidence'>(
record: Record<string, unknown>,
field: Field,
): Pick<Task, Field> | Record<string, never> {
return Object.hasOwn(record, field)
? ({ [field]: boundedTaskText(record[field], field) } as Pick<Task, Field>)
: {};
}
function optionalResumeTrust(
record: Record<string, unknown>,
): Pick<Task, 'resumeTrust'> | Record<string, never> {
if (!Object.hasOwn(record, 'resumeTrust')) return {};
if (!isResumeTrust(record.resumeTrust)) throw invalidProtocolFrame('Invalid task resumeTrust');
return { resumeTrust: record.resumeTrust };
}
function boundedTaskText(
value: unknown,
field: 'subject' | 'blockedReason' | 'failureReason' | 'completionEvidence',
): string {
const maxCharacters = field === 'subject' ? TASK_SUBJECT_MAX_CHARS : TASK_EVIDENCE_MAX_CHARS;
if (typeof value !== 'string' || value.length === 0 || Array.from(value).length > maxCharacters) {
throw invalidProtocolFrame(`Invalid task ${field}`);
}
return value;
}
function projectTaskForWire(task: Task): Task {
const sanitized = sanitizeTaskLedgerTask(task);
const { blockedReason, failureReason, completionEvidence, ...identity } = sanitized;
const projected: Task = {
...identity,
subject: canonicalWireSubject(sanitized.subject),
...canonicalWireEvidence(blockedReason, 'blockedReason'),
...canonicalWireEvidence(failureReason, 'failureReason'),
...canonicalWireEvidence(completionEvidence, 'completionEvidence'),
};
if (validateTaskEvidence(projected).ok) return projected;
if (projected.resumeTrust !== undefined && projected.resumeTrust !== 'trusted') return projected;
return { ...projected, resumeTrust: 'needs_revalidation' };
}
function hasCanonicalWireProjection(task: Task, projected: Task): boolean {
return (
task.subject === projected.subject &&
task.blockedReason === projected.blockedReason &&
task.failureReason === projected.failureReason &&
task.completionEvidence === projected.completionEvidence &&
task.resumeTrust === projected.resumeTrust
);
}
function canonicalWireSubject(value: string): string {
const normalized = normalizeTaskSubject(value);
if (normalized.ok) return normalized.value;
if (value.trim().length === 0) return REDACTED_TASK_TEXT;
throw invalidProtocolFrame('Invalid task subject');
}
function canonicalWireEvidence<
Field extends 'blockedReason' | 'failureReason' | 'completionEvidence',
>(value: string | undefined, field: Field): Pick<Task, Field> | Record<string, never> {
if (value === undefined) return {};
const normalized = normalizeTaskEvidenceText(value, field);
if (normalized.ok) return { [field]: normalized.value } as Pick<Task, Field>;
if (value.trim().length === 0) return {};
throw invalidProtocolFrame(`Invalid task ${field}`);
}
function stableTaskId(value: unknown, label: string): string {
if (!isSafeTaskId(value)) throw invalidProtocolFrame(`Invalid ${label}`);
return value;
}
function taskKey(value: unknown): string {
if (!isTaskKey(value)) throw invalidProtocolFrame('Invalid task key');
return value;
}
function taskReference(value: unknown): string {
if (!isSafeTaskId(value) && !isTaskKey(value)) {
throw invalidProtocolFrame('Invalid task reference');
}
return value;
}
function taskStatus(value: unknown): Task['status'] {
if (!isTaskStatus(value)) throw invalidProtocolFrame('Invalid task status');
return value;
}
function timestamp(value: unknown, label: string): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
return value;
}
function taskLedgerRevision(value: unknown, label: string): TaskLedgerRevision {
if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value)) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
return value as TaskLedgerRevision;
}
function taskLedgerCursor(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
value.length === 0 ||
Buffer.byteLength(value, 'utf8') > TASK_LEDGER_CURSOR_MAX_BYTES
) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
return value;
}
function assertAllowedKeys(
record: Record<string, unknown>,
label: string,
keys: readonly string[],
): void {
const allowed = new Set(keys);
if (Object.keys(record).some((key) => !allowed.has(key))) {
throw invalidProtocolFrame(`Unknown ${label} field`);
}
}
function jsonByteLength(value: unknown): number {
return Buffer.byteLength(JSON.stringify(value), 'utf8');
}