blob: 6982fe5a6a89c501a8c8afa9442157b3a880f169 [file]
import {
PLAN_EXECUTION_STATUSES,
PLAN_LIFECYCLE_REASON_MAX_BYTES,
PLAN_MAX_FILES_PER_STEP,
PLAN_MAX_RISKS,
PLAN_MAX_STEPS,
PLAN_PROPOSAL_STATUSES,
PLAN_STEP_TITLE_MAX_CHARS,
PLAN_STEP_STATUSES,
PLAN_TEXT_MAX_BYTES,
type PlanEvent,
type PlanExecution,
type PlanExecutionStatus,
type LegacyPlanProjection,
type PlanProposal,
type PlanProposalStatus,
type PlanStepDefinition,
type PlanStepStatus,
type PlanUserControlInput,
} from '@maka/core';
import {
requireCount,
requireEncodedByteLimit,
requireEntityId,
requireExactRecord,
requireRecord,
requireShapedRecord,
requireUtf8String,
} from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import { defineOperation } from './operation-spec.js';
import { decodeTurnSnapshot, type TurnSnapshot } from './turn.js';
export const PLAN_PAGE_MAX_ITEMS = 16;
export const PLAN_RESULT_MAX_BYTES = 64 * 1024;
const QUERY_ERRORS = [
'host_not_ready',
'host_draining',
'operation_unavailable',
'not_found',
'session_archived',
'invalid_request',
'internal_failure',
] as const;
const CONTROL_ERRORS = [
...QUERY_ERRORS,
'session_busy',
'operation_conflict',
'persistence_failed',
] as const;
export type PlanQueryInput =
| { readonly kind: 'list_start'; readonly sessionId: string }
| {
readonly kind: 'list_continue';
readonly sessionId: string;
readonly storeVersion: number;
readonly cursor: string;
};
export type PlanProjectionItem =
| { readonly kind: 'proposal'; readonly proposal: PlanProposal }
| { readonly kind: 'execution'; readonly execution: PlanExecution };
export type PlanQueryResult =
| {
readonly kind: 'page';
readonly sessionId: string;
readonly storeVersion: number;
readonly latestProposalId: string | null;
readonly activeExecutionId: string | null;
readonly items: readonly PlanProjectionItem[];
readonly nextCursor: string | null;
}
| {
readonly kind: 'revision_changed';
readonly expected: number;
readonly actual: number;
};
export type PlanControlInput = PlanUserControlInput;
export interface PlanControlResult {
readonly sessionId: string;
readonly storeVersion: number;
readonly eventType: PlanEvent['type'];
readonly proposalId: string | null;
readonly executionId: string | null;
}
export type PlanTurnStartInput =
| {
readonly kind: 'approve_proposal';
readonly sessionId: string;
readonly proposalId: string;
readonly expectedRevision: number;
readonly expectedStoreVersion: number;
readonly turnId: string;
}
| {
readonly kind: 'resume_execution';
readonly sessionId: string;
readonly executionId: string;
readonly turnId: string;
};
export interface PlanTurnStartResult {
readonly plan: PlanControlResult;
readonly turn: TurnSnapshot;
}
export const PLAN_OPERATION_SPECS = {
'plan.query': defineOperation<PlanQueryInput, PlanQueryResult, (typeof QUERY_ERRORS)[number]>({
mode: 'query',
availability: 'ready',
errors: QUERY_ERRORS,
decodeInput: decodePlanQueryInput,
decodeOutput: decodePlanQueryResult,
assertOutputForInput(input, output) {
if (output.kind === 'page' && output.sessionId !== input.sessionId) {
throw invalidProtocolFrame('Plan query result belongs to a different Session');
}
},
}),
'plan.control': defineOperation<
PlanControlInput,
PlanControlResult,
(typeof CONTROL_ERRORS)[number]
>({
mode: 'control',
availability: 'ready',
errors: CONTROL_ERRORS,
decodeInput: decodePlanControlInput,
decodeOutput: decodePlanControlResult,
assertOutputForInput: assertPlanControlResult,
}),
'plan.turn.start': defineOperation<
PlanTurnStartInput,
PlanTurnStartResult,
(typeof CONTROL_ERRORS)[number]
>({
mode: 'command',
availability: 'ready',
errors: CONTROL_ERRORS,
decodeInput: decodePlanTurnStartInput,
decodeOutput: decodePlanTurnStartResult,
assertOutputForInput(input, output) {
if (
output.plan.sessionId !== input.sessionId ||
output.turn.sessionId !== input.sessionId ||
output.turn.turnId !== input.turnId
) {
throw invalidProtocolFrame('Plan Turn start result changed operation identity');
}
assertPlanControlResult(planTurnControlInput(input), output.plan);
},
}),
} as const;
export function decodePlanQueryInput(value: unknown): PlanQueryInput {
const record = requireRecord(value, 'Plan query input');
if (record.kind === 'list_start') {
const input = requireExactRecord(record, 'Plan list start input', ['kind', 'sessionId']);
return { kind: 'list_start', sessionId: requireEntityId(input.sessionId, 'sessionId') };
}
if (record.kind === 'list_continue') {
const input = requireExactRecord(record, 'Plan list continuation input', [
'kind',
'sessionId',
'storeVersion',
'cursor',
]);
return {
kind: 'list_continue',
sessionId: requireEntityId(input.sessionId, 'sessionId'),
storeVersion: requireCount(input.storeVersion, 'Plan storeVersion'),
cursor: requireEntityId(input.cursor, 'Plan cursor'),
};
}
throw invalidProtocolFrame('Invalid Plan query kind');
}
export function decodePlanQueryResult(value: unknown): PlanQueryResult {
requireEncodedByteLimit(value, 'Plan query result', PLAN_RESULT_MAX_BYTES);
const record = requireRecord(value, 'Plan query result');
if (record.kind === 'revision_changed') {
const result = requireExactRecord(record, 'Plan revision changed result', [
'kind',
'expected',
'actual',
]);
return {
kind: 'revision_changed',
expected: requireCount(result.expected, 'expected Plan storeVersion'),
actual: requireCount(result.actual, 'actual Plan storeVersion'),
};
}
const result = requireExactRecord(record, 'Plan page result', [
'kind',
'sessionId',
'storeVersion',
'latestProposalId',
'activeExecutionId',
'items',
'nextCursor',
]);
if (result.kind !== 'page') throw invalidProtocolFrame('Invalid Plan query result kind');
if (!Array.isArray(result.items) || result.items.length > PLAN_PAGE_MAX_ITEMS) {
throw invalidProtocolFrame('Invalid Plan page items');
}
const decoded: PlanQueryResult = {
kind: 'page',
sessionId: requireEntityId(result.sessionId, 'sessionId'),
storeVersion: requireCount(result.storeVersion, 'Plan storeVersion'),
latestProposalId: nullableId(result.latestProposalId, 'latestProposalId'),
activeExecutionId: nullableId(result.activeExecutionId, 'activeExecutionId'),
items: result.items.map(decodePlanProjectionItem),
nextCursor: nullableId(result.nextCursor, 'nextCursor'),
};
if (
decoded.items.some((item) =>
item.kind === 'proposal'
? item.proposal.sessionId !== decoded.sessionId
: item.execution.sessionId !== decoded.sessionId,
)
) {
throw invalidProtocolFrame('Plan page contains state from another Session');
}
return decoded;
}
export function decodePlanControlInput(value: unknown): PlanControlInput {
const record = requireRecord(value, 'Plan control input');
if (record.kind === 'request_revision' || record.kind === 'abandon_proposal') {
const input = requireExactRecord(record, 'Plan proposal control input', [
'kind',
'sessionId',
'proposalId',
'operationId',
]);
return {
kind: record.kind,
sessionId: requireEntityId(input.sessionId, 'sessionId'),
proposalId: requireEntityId(input.proposalId, 'proposalId'),
operationId: requireEntityId(input.operationId, 'operationId'),
};
}
if (record.kind === 'approve_proposal') {
const input = requireExactRecord(record, 'Plan approval input', [
'kind',
'sessionId',
'proposalId',
'expectedRevision',
'expectedStoreVersion',
'operationId',
]);
return {
kind: 'approve_proposal',
sessionId: requireEntityId(input.sessionId, 'sessionId'),
proposalId: requireEntityId(input.proposalId, 'proposalId'),
expectedRevision: requireCount(input.expectedRevision, 'expectedRevision'),
expectedStoreVersion: requireCount(input.expectedStoreVersion, 'expectedStoreVersion'),
operationId: requireEntityId(input.operationId, 'operationId'),
};
}
if (record.kind === 'resume_execution' || record.kind === 'cancel_execution') {
const input = requireExactRecord(record, 'Plan execution control input', [
'kind',
'sessionId',
'executionId',
'operationId',
]);
return {
kind: record.kind,
sessionId: requireEntityId(input.sessionId, 'sessionId'),
executionId: requireEntityId(input.executionId, 'executionId'),
operationId: requireEntityId(input.operationId, 'operationId'),
};
}
throw invalidProtocolFrame('Invalid Plan control kind');
}
export function decodePlanControlResult(value: unknown): PlanControlResult {
requireEncodedByteLimit(value, 'Plan control result', PLAN_RESULT_MAX_BYTES);
const result = requireExactRecord(value, 'Plan control result', [
'sessionId',
'storeVersion',
'eventType',
'proposalId',
'executionId',
]);
return {
sessionId: requireEntityId(result.sessionId, 'sessionId'),
storeVersion: requireCount(result.storeVersion, 'Plan storeVersion'),
eventType: requirePlanEventType(result.eventType),
proposalId: nullableId(result.proposalId, 'proposalId'),
executionId: nullableId(result.executionId, 'executionId'),
};
}
export function decodePlanTurnStartInput(value: unknown): PlanTurnStartInput {
const record = requireRecord(value, 'Plan Turn start input');
if (record.kind === 'approve_proposal') {
const input = requireExactRecord(record, 'Plan approval Turn input', [
'kind',
'sessionId',
'proposalId',
'expectedRevision',
'expectedStoreVersion',
'turnId',
]);
return {
kind: 'approve_proposal',
sessionId: requireEntityId(input.sessionId, 'sessionId'),
proposalId: requireEntityId(input.proposalId, 'proposalId'),
expectedRevision: requireCount(input.expectedRevision, 'expectedRevision'),
expectedStoreVersion: requireCount(input.expectedStoreVersion, 'expectedStoreVersion'),
turnId: requireEntityId(input.turnId, 'turnId'),
};
}
if (record.kind === 'resume_execution') {
const input = requireExactRecord(record, 'Plan resume Turn input', [
'kind',
'sessionId',
'executionId',
'turnId',
]);
return {
kind: 'resume_execution',
sessionId: requireEntityId(input.sessionId, 'sessionId'),
executionId: requireEntityId(input.executionId, 'executionId'),
turnId: requireEntityId(input.turnId, 'turnId'),
};
}
throw invalidProtocolFrame('Invalid Plan Turn start kind');
}
export function decodePlanTurnStartResult(value: unknown): PlanTurnStartResult {
requireEncodedByteLimit(value, 'Plan Turn start result', PLAN_RESULT_MAX_BYTES);
const result = requireExactRecord(value, 'Plan Turn start result', ['plan', 'turn']);
return {
plan: decodePlanControlResult(result.plan),
turn: decodeTurnSnapshot(result.turn),
};
}
export function planTurnControlInput(input: PlanTurnStartInput): PlanControlInput {
return input.kind === 'approve_proposal'
? {
kind: input.kind,
sessionId: input.sessionId,
proposalId: input.proposalId,
expectedRevision: input.expectedRevision,
expectedStoreVersion: input.expectedStoreVersion,
operationId: input.turnId,
}
: {
kind: input.kind,
sessionId: input.sessionId,
executionId: input.executionId,
operationId: input.turnId,
};
}
function decodePlanProjectionItem(value: unknown): PlanProjectionItem {
const record = requireRecord(value, 'Plan projection item');
if (record.kind === 'proposal') {
const item = requireExactRecord(record, 'Plan proposal item', ['kind', 'proposal']);
return { kind: 'proposal', proposal: decodeProposal(item.proposal) };
}
if (record.kind === 'execution') {
const item = requireExactRecord(record, 'Plan execution item', ['kind', 'execution']);
return { kind: 'execution', execution: decodeExecution(item.execution) };
}
throw invalidProtocolFrame('Invalid Plan projection item kind');
}
function decodeProposal(value: unknown): PlanProposal {
const record = requireShapedRecord(
value,
'Plan proposal',
[
'planId',
'proposalId',
'sessionId',
'turnId',
'revision',
'title',
'steps',
'status',
'submittedAt',
],
['supersedesProposalId', 'sourceExecutionId', 'overview', 'risks', 'legacyProjection'],
);
return {
planId: requireEntityId(record.planId, 'planId'),
proposalId: requireEntityId(record.proposalId, 'proposalId'),
sessionId: requireEntityId(record.sessionId, 'sessionId'),
turnId: requireEntityId(record.turnId, 'turnId'),
revision: requireCount(record.revision, 'Plan proposal revision'),
...(record.supersedesProposalId === undefined
? {}
: {
supersedesProposalId: requireEntityId(
record.supersedesProposalId,
'supersedesProposalId',
),
}),
...(record.sourceExecutionId === undefined
? {}
: { sourceExecutionId: requireEntityId(record.sourceExecutionId, 'sourceExecutionId') }),
title: planText(record.title, 'Plan title'),
...(record.overview === undefined
? {}
: { overview: planText(record.overview, 'Plan overview') }),
steps: decodeArray(record.steps, 'Plan steps', PLAN_MAX_STEPS, decodeStepDefinition),
...(record.risks === undefined
? {}
: {
risks: decodeArray(record.risks, 'Plan risks', PLAN_MAX_RISKS, (item) =>
planText(item, 'Plan risk'),
),
}),
status: requireProposalStatus(record.status),
submittedAt: requireCount(record.submittedAt, 'Plan submittedAt'),
...(record.legacyProjection === undefined
? {}
: { legacyProjection: decodeLegacyProjection(record.legacyProjection) }),
};
}
function decodeExecution(value: unknown): PlanExecution {
const record = requireShapedRecord(
value,
'Plan execution',
[
'executionId',
'planId',
'proposalId',
'sessionId',
'status',
'steps',
'startedAt',
'updatedAt',
],
[
'completedAt',
'cancelledAt',
'interruptedAt',
'cancelReason',
'interruptionReason',
'legacyProjection',
],
);
return {
executionId: requireEntityId(record.executionId, 'executionId'),
planId: requireEntityId(record.planId, 'planId'),
proposalId: requireEntityId(record.proposalId, 'proposalId'),
sessionId: requireEntityId(record.sessionId, 'sessionId'),
status: requireExecutionStatus(record.status),
steps: decodeArray(record.steps, 'Plan execution steps', PLAN_MAX_STEPS, (item) => {
const step = decodeStepDefinition(item);
const source = requireShapedRecord(
item,
'Plan execution step',
['id', 'title', 'description', 'status', 'updatedAt'],
['files', 'complexity', 'note'],
);
return {
...step,
status: requireStepStatus(source.status),
...(source.note === undefined ? {} : { note: planText(source.note, 'Plan step note') }),
updatedAt: requireCount(source.updatedAt, 'Plan step updatedAt'),
};
}),
startedAt: requireCount(record.startedAt, 'Plan startedAt'),
updatedAt: requireCount(record.updatedAt, 'Plan updatedAt'),
...optionalCount(record, 'completedAt'),
...optionalCount(record, 'cancelledAt'),
...optionalCount(record, 'interruptedAt'),
...(record.cancelReason === undefined
? {}
: {
cancelReason: lifecycleReason(record.cancelReason, 'Plan cancelReason'),
}),
...(record.interruptionReason === undefined
? {}
: {
interruptionReason: lifecycleReason(record.interruptionReason, 'Plan interruptionReason'),
}),
...(record.legacyProjection === undefined
? {}
: { legacyProjection: decodeLegacyProjection(record.legacyProjection) }),
};
}
function decodeLegacyProjection(value: unknown): LegacyPlanProjection {
const record = requireExactRecord(value, 'Legacy Plan projection', ['truncated']);
if (record.truncated !== true) {
throw invalidProtocolFrame('Invalid legacy Plan projection');
}
return { truncated: true };
}
function decodeStepDefinition(value: unknown): PlanStepDefinition {
const record = requireShapedRecord(
value,
'Plan step',
['id', 'title', 'description'],
['files', 'complexity', 'status', 'note', 'updatedAt'],
);
const complexity = record.complexity;
if (complexity !== undefined && !['low', 'medium', 'high'].includes(String(complexity))) {
throw invalidProtocolFrame('Invalid Plan step complexity');
}
const title = planText(record.title, 'Plan step title');
if (title.length > PLAN_STEP_TITLE_MAX_CHARS) {
throw invalidProtocolFrame('Invalid Plan step title');
}
return {
id: requireEntityId(record.id, 'Plan step id'),
title,
description: planText(record.description, 'Plan step description'),
...(record.files === undefined
? {}
: {
files: decodeArray(record.files, 'Plan step files', PLAN_MAX_FILES_PER_STEP, (item) =>
planText(item, 'Plan file'),
),
}),
...(complexity === undefined
? {}
: { complexity: complexity as PlanStepDefinition['complexity'] }),
};
}
function decodeArray<T>(
value: unknown,
label: string,
maxItems: number,
decode: (item: unknown) => T,
): T[] {
if (!Array.isArray(value) || value.length > maxItems) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
return value.map(decode);
}
function planText(value: unknown, label: string): string {
return requireUtf8String(value, label, PLAN_TEXT_MAX_BYTES);
}
function lifecycleReason(value: unknown, label: string): string {
return requireUtf8String(value, label, PLAN_LIFECYCLE_REASON_MAX_BYTES);
}
function nullableId(value: unknown, label: string): string | null {
return value === null ? null : requireEntityId(value, label);
}
function optionalCount(record: Record<string, unknown>, key: string): Record<string, number> {
return record[key] === undefined ? {} : { [key]: requireCount(record[key], `Plan ${key}`) };
}
function requireProposalStatus(value: unknown): PlanProposalStatus {
if (!PLAN_PROPOSAL_STATUSES.includes(value as PlanProposalStatus)) {
throw invalidProtocolFrame('Invalid Plan proposal status');
}
return value as PlanProposalStatus;
}
function requireExecutionStatus(value: unknown): PlanExecutionStatus {
if (!PLAN_EXECUTION_STATUSES.includes(value as PlanExecutionStatus)) {
throw invalidProtocolFrame('Invalid Plan execution status');
}
return value as PlanExecutionStatus;
}
function requireStepStatus(value: unknown): PlanStepStatus {
if (!PLAN_STEP_STATUSES.includes(value as PlanStepStatus)) {
throw invalidProtocolFrame('Invalid Plan step status');
}
return value as PlanStepStatus;
}
function requirePlanEventType(value: unknown): PlanEvent['type'] {
const types: readonly PlanEvent['type'][] = [
'plan_submitted',
'plan_revision_requested',
'plan_abandoned',
'plan_approved',
'plan_progress_updated',
'plan_execution_completed',
'plan_execution_cancelled',
'plan_execution_interrupted',
'plan_execution_resumed',
];
if (!types.includes(value as PlanEvent['type'])) {
throw invalidProtocolFrame('Invalid Plan eventType');
}
return value as PlanEvent['type'];
}
function assertPlanControlResult(input: PlanControlInput, output: PlanControlResult): void {
if (input.sessionId !== output.sessionId) {
throw invalidProtocolFrame('Plan control result belongs to a different Session');
}
if (input.kind === 'request_revision') {
assertProposalControl(output, input.proposalId, 'plan_revision_requested');
return;
}
if (input.kind === 'abandon_proposal') {
assertProposalControl(output, input.proposalId, 'plan_abandoned');
return;
}
if (input.kind === 'approve_proposal') {
if (
output.eventType !== 'plan_approved' ||
output.proposalId !== input.proposalId ||
output.executionId === null
) {
throw invalidProtocolFrame('Plan approval result does not match its request');
}
return;
}
const expectedType =
input.kind === 'resume_execution' ? 'plan_execution_resumed' : 'plan_execution_cancelled';
if (!('executionId' in input)) {
throw invalidProtocolFrame('Invalid Plan execution control correlation');
}
if (
output.eventType !== expectedType ||
output.executionId !== input.executionId ||
output.proposalId !== null
) {
throw invalidProtocolFrame('Plan execution control result does not match its request');
}
}
function assertProposalControl(
output: PlanControlResult,
proposalId: string,
eventType: 'plan_revision_requested' | 'plan_abandoned',
): void {
if (
output.eventType !== eventType ||
output.proposalId !== proposalId ||
output.executionId !== null
) {
throw invalidProtocolFrame('Plan proposal control result does not match its request');
}
}