blob: 7f6c5e22f4ee75315823ea3454997c4eab514184 [file]
export const PLAN_PROPOSAL_STATUSES = ['pending_approval', 'stale', 'approved'] as const;
export type PlanProposalStatus = (typeof PLAN_PROPOSAL_STATUSES)[number];
export const PLAN_EXECUTION_STATUSES = ['active', 'completed', 'cancelled', 'interrupted'] as const;
export type PlanExecutionStatus = (typeof PLAN_EXECUTION_STATUSES)[number];
export const PLAN_STEP_STATUSES = ['pending', 'in_progress', 'completed', 'skipped'] as const;
export type PlanStepStatus = (typeof PLAN_STEP_STATUSES)[number];
export const PLAN_ENTITY_ID_MAX_CHARS = 128;
export const PLAN_TEXT_MAX_BYTES = 16 * 1024;
export const PLAN_LIFECYCLE_REASON_MAX_BYTES = 1024;
export const PLAN_PROJECTION_ITEM_MAX_BYTES = 60 * 1024;
export const PLAN_MAX_STEPS = 50;
export const PLAN_MAX_FILES_PER_STEP = 50;
export const PLAN_MAX_RISKS = 20;
export const PLAN_STEP_TITLE_MAX_CHARS = 30;
const PLAN_ENTITY_ID_PATTERN = new RegExp(`^[A-Za-z0-9_-]{1,${PLAN_ENTITY_ID_MAX_CHARS}}$`);
const PLAN_TEXT_ENCODER = new TextEncoder();
export function isCanonicalPlanEntityId(value: string): boolean {
return PLAN_ENTITY_ID_PATTERN.test(value);
}
export function planEncodedByteLength(value: unknown): number {
return PLAN_TEXT_ENCODER.encode(JSON.stringify(value) ?? 'null').byteLength;
}
export function isPlanTextWithinLimit(value: string, maxBytes = PLAN_TEXT_MAX_BYTES): boolean {
return PLAN_TEXT_ENCODER.encode(value).byteLength <= maxBytes;
}
export interface PlanStepDefinition {
id: string;
title: string;
description: string;
files?: string[];
complexity?: 'low' | 'medium' | 'high';
}
export interface LegacyPlanProjection {
/** The pre-Host ledger required bounded content or identity projection. */
truncated: true;
}
export interface PlanProposal {
planId: string;
proposalId: string;
sessionId: string;
turnId: string;
revision: number;
supersedesProposalId?: string;
/** Interrupted execution whose remaining work this proposal replans. */
sourceExecutionId?: string;
title: string;
overview?: string;
steps: PlanStepDefinition[];
risks?: string[];
status: PlanProposalStatus;
submittedAt: number;
legacyProjection?: LegacyPlanProjection;
}
export interface PlanExecutionStep extends PlanStepDefinition {
status: PlanStepStatus;
note?: string;
updatedAt: number;
}
export interface PlanExecution {
executionId: string;
planId: string;
proposalId: string;
sessionId: string;
status: PlanExecutionStatus;
steps: PlanExecutionStep[];
startedAt: number;
updatedAt: number;
completedAt?: number;
cancelledAt?: number;
interruptedAt?: number;
cancelReason?: string;
interruptionReason?: string;
legacyProjection?: LegacyPlanProjection;
}
export interface PlanSessionState {
schemaVersion: 1;
sessionId: string;
storeVersion: number;
proposals: PlanProposal[];
executions: PlanExecution[];
latestProposalId?: string;
activeExecutionId?: string;
}
interface PlanEventBase {
id: string;
/** Fingerprint of the mutation input used to reconcile an exact retry. */
operationFingerprint?: string;
sessionId: string;
ts: number;
storeVersion: number;
/** Read-time compatibility marker; new canonical events never persist it. */
legacyProjection?: LegacyPlanProjection;
}
export type PlanEvent =
| (PlanEventBase & {
type: 'plan_submitted';
proposal: PlanProposal;
})
| (PlanEventBase & {
type: 'plan_revision_requested';
proposalId: string;
})
| (PlanEventBase & {
type: 'plan_abandoned';
proposalId: string;
reason: string;
})
| (PlanEventBase & {
type: 'plan_approved';
proposalId: string;
execution: PlanExecution;
})
| (PlanEventBase & {
type: 'plan_progress_updated';
executionId: string;
steps: PlanExecutionStep[];
explanation?: string;
})
| (PlanEventBase & {
type: 'plan_execution_completed';
executionId: string;
steps: PlanExecutionStep[];
})
| (PlanEventBase & {
type: 'plan_execution_cancelled';
executionId: string;
reason: string;
})
| (PlanEventBase & {
type: 'plan_execution_interrupted';
executionId: string;
reason: string;
})
| (PlanEventBase & {
type: 'plan_execution_resumed';
executionId: string;
});
export interface SubmitPlanProposalInput {
operationId?: string;
sessionId: string;
turnId: string;
title: string;
overview?: string;
steps: PlanStepDefinition[];
risks?: string[];
sourceExecutionId?: string;
}
export function isPlanProposalLifecycleAdmissible(
input: Pick<SubmitPlanProposalInput, 'title' | 'overview' | 'steps' | 'risks'>,
): boolean {
const id = 'x'.repeat(PLAN_ENTITY_ID_MAX_CHARS);
const timestamp = Number.MAX_SAFE_INTEGER;
const proposal: PlanProposal = {
planId: id,
proposalId: id,
sessionId: id,
turnId: id,
revision: Number.MAX_SAFE_INTEGER,
supersedesProposalId: id,
sourceExecutionId: id,
title: input.title,
...(input.overview ? { overview: input.overview } : {}),
steps: structuredClone(input.steps),
...(input.risks ? { risks: structuredClone(input.risks) } : {}),
status: 'pending_approval',
submittedAt: timestamp,
};
const execution = worstCasePlanExecution(proposal, id, timestamp);
return (
planEncodedByteLength({ kind: 'proposal', proposal }) <= PLAN_PROJECTION_ITEM_MAX_BYTES &&
planEncodedByteLength({ kind: 'execution', execution }) <= PLAN_PROJECTION_ITEM_MAX_BYTES
);
}
export function worstCasePlanExecution(
proposal: Pick<PlanProposal, 'planId' | 'proposalId' | 'sessionId' | 'steps'>,
executionId: string,
timestamp = Number.MAX_SAFE_INTEGER,
): PlanExecution {
const reason = 'x'.repeat(PLAN_LIFECYCLE_REASON_MAX_BYTES);
return {
executionId,
planId: proposal.planId,
proposalId: proposal.proposalId,
sessionId: proposal.sessionId,
status: 'cancelled',
steps: proposal.steps.map((step) => ({
...structuredClone(step),
status: 'pending',
updatedAt: timestamp,
})),
startedAt: timestamp,
updatedAt: timestamp,
interruptedAt: timestamp,
interruptionReason: reason,
cancelledAt: timestamp,
cancelReason: reason,
};
}
export interface ApprovePlanProposalInput {
operationId?: string;
sessionId: string;
proposalId: string;
expectedRevision: number;
expectedStoreVersion?: number;
}
export interface RequestPlanRevisionInput {
operationId?: string;
sessionId: string;
proposalId: string;
}
export interface AbandonPlanProposalInput {
operationId?: string;
sessionId: string;
proposalId: string;
reason: string;
}
export interface UpdatePlanExecutionInput {
operationId?: string;
sessionId: string;
executionId: string;
steps: Array<{
id: string;
status: PlanStepStatus;
note?: string;
}>;
explanation?: string;
}
export interface CancelPlanExecutionInput {
operationId?: string;
sessionId: string;
executionId: string;
reason: string;
}
export interface PlanMutationResult {
event: PlanEvent;
state: PlanSessionState;
}
export const PLAN_USER_ABANDON_REASON = 'User exited Plan Mode before approval.';
export const PLAN_USER_CANCEL_REASON = 'User abandoned the interrupted plan.';
export type PlanUserControlInput =
| {
readonly kind: 'request_revision' | 'abandon_proposal';
readonly sessionId: string;
readonly proposalId: string;
readonly operationId: string;
}
| {
readonly kind: 'approve_proposal';
readonly sessionId: string;
readonly proposalId: string;
readonly expectedRevision: number;
readonly expectedStoreVersion: number;
readonly operationId: string;
}
| {
readonly kind: 'resume_execution' | 'cancel_execution';
readonly sessionId: string;
readonly executionId: string;
readonly operationId: string;
};
export function planUserControlMutationInput(
input: PlanUserControlInput,
):
| RequestPlanRevisionInput
| AbandonPlanProposalInput
| ApprovePlanProposalInput
| CancelPlanExecutionInput
| { operationId: string; sessionId: string; executionId: string } {
switch (input.kind) {
case 'request_revision':
return {
operationId: input.operationId,
sessionId: input.sessionId,
proposalId: input.proposalId,
};
case 'abandon_proposal':
return {
operationId: input.operationId,
sessionId: input.sessionId,
proposalId: input.proposalId,
reason: PLAN_USER_ABANDON_REASON,
};
case 'approve_proposal':
return {
operationId: input.operationId,
sessionId: input.sessionId,
proposalId: input.proposalId,
expectedRevision: input.expectedRevision,
expectedStoreVersion: input.expectedStoreVersion,
};
case 'resume_execution':
return {
operationId: input.operationId,
sessionId: input.sessionId,
executionId: input.executionId,
};
case 'cancel_execution':
return {
operationId: input.operationId,
sessionId: input.sessionId,
executionId: input.executionId,
reason: PLAN_USER_CANCEL_REASON,
};
}
}
export interface PlanStore {
readState(sessionId: string): Promise<PlanSessionState>;
readOperationReceipt(
sessionId: string,
operationId: string,
operationInput: unknown,
): Promise<PlanEvent | undefined>;
submitProposal(input: SubmitPlanProposalInput): Promise<PlanMutationResult>;
requestRevision(input: RequestPlanRevisionInput): Promise<PlanMutationResult>;
abandonProposal(input: AbandonPlanProposalInput): Promise<PlanMutationResult>;
approveProposal(input: ApprovePlanProposalInput): Promise<PlanMutationResult>;
updateExecution(input: UpdatePlanExecutionInput): Promise<PlanMutationResult>;
cancelExecution(input: CancelPlanExecutionInput): Promise<PlanMutationResult>;
interruptActiveExecution(
sessionId: string,
reason: string,
operationId?: string,
): Promise<PlanMutationResult | null>;
resumeExecution(
sessionId: string,
executionId: string,
operationId?: string,
): Promise<PlanMutationResult>;
}
export class PlanConflictError extends Error {
constructor(message: string) {
super(message);
this.name = 'PlanConflictError';
}
}
export function emptyPlanSessionState(sessionId: string): PlanSessionState {
return {
schemaVersion: 1,
sessionId,
storeVersion: 0,
proposals: [],
executions: [],
};
}
export function activePlanExecution(state: PlanSessionState): PlanExecution | undefined {
if (!state.activeExecutionId) return undefined;
return state.executions.find((execution) => execution.executionId === state.activeExecutionId);
}
export function latestPlanProposal(state: PlanSessionState): PlanProposal | undefined {
if (!state.latestProposalId) return undefined;
return state.proposals.find((proposal) => proposal.proposalId === state.latestProposalId);
}