blob: 87199e09733af5800e9247c7f5d158c419105348 [file]
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import type { DatabaseSync } from 'node:sqlite';
import {
PlanConflictError,
activePlanExecution,
type AbandonPlanProposalInput,
emptyPlanSessionState,
latestPlanProposal,
type ApprovePlanProposalInput,
type CancelPlanExecutionInput,
type PlanEvent,
type PlanExecution,
type PlanExecutionStep,
type PlanMutationResult,
type PlanProposal,
type PlanSessionState,
type PlanStepDefinition,
type PlanStore,
type RequestPlanRevisionInput,
type SubmitPlanProposalInput,
type UpdatePlanExecutionInput,
} from '@maka/core/plan';
import { appendJsonl } from './jsonl-append.js';
import { classifyJsonRecord } from './json-prefix.js';
import { chainWrite } from './write-queue.js';
import {
acquireOperationalStateDatabase,
completeOperationalStoreCutover,
type OperationalStateDatabaseLease,
type OperationalStoreCutoverFailpoint,
} from './operational-state-store.js';
const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
export interface CreatePlanStoreOptions {
newId?: () => string;
now?: () => number;
}
export function createPlanStore(
workspaceRoot: string,
options: CreatePlanStoreOptions = {},
): PlanStore {
return new FilePlanStore(workspaceRoot, options);
}
export interface SqlitePlanStore extends PlanStore {
ready(): Promise<void>;
close(): void;
}
export interface CreateSqlitePlanStoreOptions extends CreatePlanStoreOptions {
failpoint?: (point: OperationalStoreCutoverFailpoint) => void;
}
export function createSqlitePlanStore(
workspaceRoot: string,
options: CreateSqlitePlanStoreOptions = {},
): SqlitePlanStore {
return new SqlitePlanStoreImpl(workspaceRoot, options);
}
class FilePlanStore implements PlanStore {
private readonly durabilityRoot: string;
private readonly sessionsRoot: string;
private readonly queues = new Map<string, Promise<void>>();
private readonly newId: () => string;
private readonly now: () => number;
constructor(workspaceRoot: string, options: CreatePlanStoreOptions) {
this.durabilityRoot = resolve(workspaceRoot);
this.sessionsRoot = join(this.durabilityRoot, 'sessions');
this.newId = options.newId ?? randomUUID;
this.now = options.now ?? Date.now;
}
async readState(sessionId: string): Promise<PlanSessionState> {
return (await this.readLedger(sessionId)).state;
}
async submitProposal(input: SubmitPlanProposalInput): Promise<PlanMutationResult> {
return this.mutate(input.sessionId, async (state) => {
const title = requiredText(input.title, 'Plan title');
const steps = normalizeDefinitions(input.steps);
const latest = latestPlanProposal(state);
if (state.activeExecutionId) {
throw new PlanConflictError('Cannot submit a new proposal while a plan is executing');
}
const sourceExecution = input.sourceExecutionId
? executionById(state, input.sourceExecutionId)
: undefined;
if (sourceExecution && sourceExecution.status !== 'interrupted') {
throw new PlanConflictError('Only an interrupted execution can be replanned');
}
const revisesLatest =
latest !== undefined &&
(latest.status !== 'approved' || sourceExecution?.proposalId === latest.proposalId);
const planId = revisesLatest ? latest.planId : this.newId();
const proposalId = this.newId();
const submittedAt = this.now();
const proposal: PlanProposal = {
planId,
proposalId,
sessionId: input.sessionId,
turnId: input.turnId,
revision: revisesLatest ? latest.revision + 1 : 1,
...(revisesLatest ? { supersedesProposalId: latest.proposalId } : {}),
...(sourceExecution ? { sourceExecutionId: sourceExecution.executionId } : {}),
title,
...(optionalText(input.overview) ? { overview: optionalText(input.overview) } : {}),
steps,
...(input.risks && input.risks.length > 0
? { risks: input.risks.map((risk) => requiredText(risk, 'Plan risk')) }
: {}),
status: 'pending_approval',
submittedAt,
};
return {
type: 'plan_submitted',
id: this.newId(),
sessionId: input.sessionId,
ts: submittedAt,
storeVersion: state.storeVersion + 1,
proposal,
};
});
}
async requestRevision(input: RequestPlanRevisionInput): Promise<PlanMutationResult> {
return this.mutate(input.sessionId, async (state) => {
const proposal = proposalById(state, input.proposalId);
if (proposal.status === 'stale') {
throw new PlanConflictError('Plan proposal is already stale');
}
if (proposal.status === 'approved') {
throw new PlanConflictError('An approved plan proposal cannot be revised');
}
if (state.latestProposalId !== proposal.proposalId) {
throw new PlanConflictError('Only the latest plan proposal can be revised');
}
return {
type: 'plan_revision_requested',
id: this.newId(),
sessionId: input.sessionId,
ts: this.now(),
storeVersion: state.storeVersion + 1,
proposalId: proposal.proposalId,
};
});
}
async abandonProposal(input: AbandonPlanProposalInput): Promise<PlanMutationResult> {
return this.mutate(input.sessionId, async (state) => {
const proposal = proposalById(state, input.proposalId);
if (
proposal.status !== 'pending_approval' ||
state.latestProposalId !== proposal.proposalId
) {
throw new PlanConflictError('Only the latest pending plan proposal can be abandoned');
}
return {
type: 'plan_abandoned',
id: this.newId(),
sessionId: input.sessionId,
ts: this.now(),
storeVersion: state.storeVersion + 1,
proposalId: proposal.proposalId,
reason: requiredText(input.reason, 'Plan abandonment reason'),
};
});
}
async approveProposal(input: ApprovePlanProposalInput): Promise<PlanMutationResult> {
let duplicate: PlanMutationResult | undefined;
const result = await this.mutateOptional(input.sessionId, async (state, events) => {
const proposal = proposalById(state, input.proposalId);
if (proposal.revision !== input.expectedRevision) {
throw new PlanConflictError('Plan proposal revision does not match');
}
if (proposal.status === 'approved') {
const prior = [...events]
.reverse()
.find(
(event): event is Extract<PlanEvent, { type: 'plan_approved' }> =>
event.type === 'plan_approved' && event.proposalId === proposal.proposalId,
);
if (!prior) throw new PlanConflictError('Approved plan execution is missing');
duplicate = { event: prior, state };
return null;
}
if (
input.expectedStoreVersion !== undefined &&
state.storeVersion !== input.expectedStoreVersion
) {
throw new PlanConflictError('Plan state changed before approval');
}
if (
proposal.status !== 'pending_approval' ||
state.latestProposalId !== proposal.proposalId
) {
throw new PlanConflictError('Only the latest pending plan proposal can be approved');
}
if (state.activeExecutionId) {
throw new PlanConflictError('This session already has an active plan execution');
}
if (proposal.sourceExecutionId) {
const sourceExecution = executionById(state, proposal.sourceExecutionId);
if (sourceExecution.status !== 'interrupted') {
throw new PlanConflictError('The execution being replanned is no longer interrupted');
}
}
const startedAt = this.now();
const execution: PlanExecution = {
executionId: this.newId(),
planId: proposal.planId,
proposalId: proposal.proposalId,
sessionId: input.sessionId,
status: 'active',
steps: proposal.steps.map((step) => ({
...structuredClone(step),
status: 'pending',
updatedAt: startedAt,
})),
startedAt,
updatedAt: startedAt,
};
return {
type: 'plan_approved',
id: this.newId(),
sessionId: input.sessionId,
ts: startedAt,
storeVersion: state.storeVersion + 1,
proposalId: proposal.proposalId,
execution,
};
});
if (duplicate) return duplicate;
if (!result) throw new Error('Plan approval completed without a result');
return result;
}
async updateExecution(input: UpdatePlanExecutionInput): Promise<PlanMutationResult> {
return this.mutate(input.sessionId, async (state) => {
const execution = requireActiveExecution(state, input.executionId);
const steps = mergeExecutionSteps(execution, input.steps, this.now());
const completed = steps.every(
(step) => step.status === 'completed' || step.status === 'skipped',
);
return completed
? {
type: 'plan_execution_completed',
id: this.newId(),
sessionId: input.sessionId,
ts: this.now(),
storeVersion: state.storeVersion + 1,
executionId: execution.executionId,
steps,
}
: {
type: 'plan_progress_updated',
id: this.newId(),
sessionId: input.sessionId,
ts: this.now(),
storeVersion: state.storeVersion + 1,
executionId: execution.executionId,
steps,
...(optionalText(input.explanation)
? { explanation: optionalText(input.explanation) }
: {}),
};
});
}
async cancelExecution(input: CancelPlanExecutionInput): Promise<PlanMutationResult> {
return this.mutate(input.sessionId, async (state) => {
const execution = requireCancellableExecution(state, input.executionId);
return {
type: 'plan_execution_cancelled',
id: this.newId(),
sessionId: input.sessionId,
ts: this.now(),
storeVersion: state.storeVersion + 1,
executionId: execution.executionId,
reason: requiredText(input.reason, 'Plan cancellation reason'),
};
});
}
async interruptActiveExecution(
sessionId: string,
reason: string,
): Promise<PlanMutationResult | null> {
return this.mutateOptional(sessionId, async (fresh) => {
const execution = activePlanExecution(fresh);
if (!execution) return null;
return {
type: 'plan_execution_interrupted',
id: this.newId(),
sessionId,
ts: this.now(),
storeVersion: fresh.storeVersion + 1,
executionId: execution.executionId,
reason: requiredText(reason, 'Plan interruption reason'),
};
});
}
async resumeExecution(sessionId: string, executionId: string): Promise<PlanMutationResult> {
return this.mutate(sessionId, async (state) => {
if (state.activeExecutionId) {
throw new PlanConflictError('This session already has an active plan execution');
}
const execution = executionById(state, executionId);
if (execution.status !== 'interrupted') {
throw new PlanConflictError('Only an interrupted plan execution can be resumed');
}
return {
type: 'plan_execution_resumed',
id: this.newId(),
sessionId,
ts: this.now(),
storeVersion: state.storeVersion + 1,
executionId,
};
});
}
private async mutate(
sessionId: string,
build: (state: PlanSessionState, events: readonly PlanEvent[]) => Promise<PlanEvent | null>,
): Promise<PlanMutationResult> {
const result = await this.mutateOptional(sessionId, build);
if (!result) throw new Error('Plan mutation completed without a result');
return result;
}
private async mutateOptional(
sessionId: string,
build: (state: PlanSessionState, events: readonly PlanEvent[]) => Promise<PlanEvent | null>,
): Promise<PlanMutationResult | null> {
assertSafeId(sessionId);
let result: PlanMutationResult | null = null;
await chainWrite(this.queues, sessionId, async () => {
const ledger = await this.readLedger(sessionId);
const event = await build(ledger.state, ledger.events);
if (!event) return;
const state = applyPlanEvent(ledger.state, event);
await this.appendCanonicalEvent(sessionId, event, state);
result = { event, state };
});
return result;
}
private async readLedger(
sessionId: string,
): Promise<{ events: PlanEvent[]; state: PlanSessionState }> {
assertSafeId(sessionId);
const canonical = await this.readCanonicalLedger(sessionId);
if (canonical) return canonical;
let text: string;
try {
text = await readFile(this.eventsPath(sessionId), 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { events: [], state: emptyPlanSessionState(sessionId) };
}
throw error;
}
const rawLines = text.split('\n');
const events: PlanEvent[] = [];
for (let index = 0; index < rawLines.length; index += 1) {
const line = rawLines[index]!;
if (!line.trim()) continue;
try {
events.push(decodePlanEvent(JSON.parse(line), sessionId));
} catch (error) {
const isLast = index === rawLines.length - 1;
if (isLast && !text.endsWith('\n') && classifyJsonRecord(line) === 'incomplete-prefix') {
continue;
}
throw new Error(`Invalid Plan event at line ${index + 1}`, { cause: error });
}
}
let state = emptyPlanSessionState(sessionId);
for (const event of events) state = applyPlanEvent(state, event);
return { events, state };
}
protected async readCanonicalLedger(
_sessionId: string,
): Promise<{ events: PlanEvent[]; state: PlanSessionState } | undefined> {
return undefined;
}
protected async appendCanonicalEvent(
sessionId: string,
event: PlanEvent,
state: PlanSessionState,
): Promise<void> {
await mkdir(this.sessionDir(sessionId), { recursive: true });
await appendJsonl(this.eventsPath(sessionId), `${JSON.stringify(event)}\n`, {
durable: true,
durabilityRoot: this.durabilityRoot,
});
await this.writeProjection(sessionId, state).catch(() => {
// Derived cache only. The append-only event ledger remains authoritative.
});
}
private async writeProjection(sessionId: string, state: PlanSessionState): Promise<void> {
const path = this.projectionPath(sessionId);
await mkdir(dirname(path), { recursive: true });
const temp = `${path}.${process.pid}.${this.newId()}.tmp`;
await writeFile(temp, `${JSON.stringify(state)}\n`, { encoding: 'utf8', mode: 0o600 });
try {
await rename(temp, path);
} finally {
await rm(temp, { force: true }).catch(() => {});
}
}
private sessionDir(sessionId: string): string {
return join(this.sessionsRoot, sessionId);
}
private eventsPath(sessionId: string): string {
return join(this.sessionDir(sessionId), 'plan-events.jsonl');
}
private projectionPath(sessionId: string): string {
return join(this.sessionDir(sessionId), 'plans.json');
}
}
class SqlitePlanStoreImpl extends FilePlanStore implements SqlitePlanStore {
readonly #root: string;
readonly #lease: OperationalStateDatabaseLease;
readonly #ready: Promise<void>;
constructor(workspaceRoot: string, options: CreateSqlitePlanStoreOptions) {
super(workspaceRoot, options);
this.#root = resolve(workspaceRoot);
this.#lease = acquireOperationalStateDatabase(this.#root);
this.#ready = importLegacyPlanState(this.#root, this.#lease, options.failpoint);
}
ready(): Promise<void> {
return this.#ready;
}
close(): void {
this.#lease.close();
}
protected override async appendCanonicalEvent(
sessionId: string,
event: PlanEvent,
state: PlanSessionState,
): Promise<void> {
await this.#ready;
this.#lease.transaction('write', () => {
insertPlanEvent(this.#lease.database, event);
writePlanProjection(this.#lease.database, sessionId, state);
});
}
protected override async readCanonicalLedger(
sessionId: string,
): Promise<{ events: PlanEvent[]; state: PlanSessionState } | undefined> {
await this.#ready;
return readSqlitePlanLedger(this.#lease.database, sessionId);
}
}
interface LegacyPlanLedger {
sessionId: string;
events: PlanEvent[];
state: PlanSessionState;
}
async function importLegacyPlanState(
root: string,
lease: OperationalStateDatabaseLease,
failpoint?: (point: OperationalStoreCutoverFailpoint) => void,
): Promise<void> {
const ledgers = await readLegacyPlanLedgers(root);
const fingerprint = `sha256:${createHash('sha256')
.update(JSON.stringify(ledgers))
.digest('hex')}`;
completeOperationalStoreCutover(lease, {
storeName: 'workflow_plan',
sourcePath: join(root, 'sessions'),
sourceFingerprint: fingerprint,
failpoint,
importAndValidate: (database) => {
let eventCount = 0;
for (const ledger of ledgers) {
for (const event of ledger.events) {
insertOrValidatePlanEvent(database, event);
eventCount += 1;
}
writePlanProjection(database, ledger.sessionId, ledger.state);
}
const persisted = database
.prepare('SELECT COUNT(*) AS count FROM workflow_plan_events')
.get() as { count?: unknown };
if (persisted.count !== eventCount) {
throw new Error('Plan cutover row-count validation failed');
}
return { sessions: ledgers.length, events: eventCount };
},
});
}
async function readLegacyPlanLedgers(root: string): Promise<LegacyPlanLedger[]> {
const sessionsRoot = join(root, 'sessions');
let entries;
try {
entries = await readdir(sessionsRoot, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
const result: LegacyPlanLedger[] = [];
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
if (!entry.isDirectory() || !SAFE_ID_PATTERN.test(entry.name)) continue;
const eventsPath = join(sessionsRoot, entry.name, 'plan-events.jsonl');
let text: string | undefined;
try {
text = await readFile(eventsPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const events = text === undefined ? [] : decodeLegacyPlanEventText(text, entry.name);
let state = emptyPlanSessionState(entry.name);
for (const event of events) state = applyPlanEvent(state, event);
const projectionPath = join(sessionsRoot, entry.name, 'plans.json');
try {
const projection = JSON.parse(await readFile(projectionPath, 'utf8')) as PlanSessionState;
if (JSON.stringify(projection) !== JSON.stringify(state)) {
throw new Error(`Plan projection does not match its event ledger for ${entry.name}`);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
if (events.length > 0) result.push({ sessionId: entry.name, events, state });
}
return result;
}
function decodeLegacyPlanEventText(text: string, sessionId: string): PlanEvent[] {
const lines = text.split('\n');
const events: PlanEvent[] = [];
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]!;
if (!line.trim()) continue;
try {
events.push(decodePlanEvent(JSON.parse(line), sessionId));
} catch (error) {
const isLast = index === lines.length - 1;
if (isLast && !text.endsWith('\n') && classifyJsonRecord(line) === 'incomplete-prefix') {
continue;
}
throw new Error(`Invalid Plan event at line ${index + 1}`, { cause: error });
}
}
return events;
}
function readSqlitePlanLedger(
database: DatabaseSync,
sessionId: string,
): { events: PlanEvent[]; state: PlanSessionState } {
assertSafeId(sessionId);
const rows = database
.prepare(`
SELECT record_json
FROM workflow_plan_events
WHERE session_id = ?
ORDER BY sequence
`)
.all(sessionId) as Array<{ record_json?: unknown }>;
const events = rows.map((row, index) => {
if (typeof row.record_json !== 'string') {
throw new Error(`Invalid SQLite Plan event at sequence ${index}`);
}
return decodePlanEvent(JSON.parse(row.record_json), sessionId);
});
let state = emptyPlanSessionState(sessionId);
for (const event of events) state = applyPlanEvent(state, event);
return { events, state };
}
function insertPlanEvent(database: DatabaseSync, event: PlanEvent): void {
const row = database
.prepare(`
SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence
FROM workflow_plan_events
WHERE session_id = ?
`)
.get(event.sessionId) as { sequence?: unknown };
if (typeof row.sequence !== 'number' || !Number.isSafeInteger(row.sequence)) {
throw new Error('Invalid next Plan event sequence');
}
database
.prepare(`
INSERT INTO workflow_plan_events(
session_id, sequence, event_id, store_version, record_json
) VALUES (?, ?, ?, ?, ?)
`)
.run(event.sessionId, row.sequence, event.id, event.storeVersion, JSON.stringify(event));
}
function insertOrValidatePlanEvent(database: DatabaseSync, event: PlanEvent): void {
const existing = database
.prepare(`
SELECT record_json
FROM workflow_plan_events
WHERE session_id = ? AND store_version = ?
`)
.get(event.sessionId, event.storeVersion) as { record_json?: unknown } | undefined;
if (existing) {
if (existing.record_json !== JSON.stringify(event)) {
throw new Error(`Plan cutover conflict: ${event.sessionId}:${event.storeVersion}`);
}
return;
}
insertPlanEvent(database, event);
}
function writePlanProjection(
database: DatabaseSync,
sessionId: string,
state: PlanSessionState,
): void {
database
.prepare(`
INSERT INTO workflow_plan_projections(session_id, store_version, record_json)
VALUES (?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
store_version = excluded.store_version,
record_json = excluded.record_json
`)
.run(sessionId, state.storeVersion, JSON.stringify(state));
}
export function applyPlanEvent(state: PlanSessionState, event: PlanEvent): PlanSessionState {
if (event.sessionId !== state.sessionId) throw new Error('Plan event session mismatch');
if (event.storeVersion !== state.storeVersion + 1) {
throw new Error('Plan event storeVersion is not contiguous');
}
const next = structuredClone(state);
next.storeVersion = event.storeVersion;
switch (event.type) {
case 'plan_submitted': {
const prior = latestPlanProposal(next);
if (prior && prior.status === 'pending_approval') prior.status = 'stale';
next.proposals.push(structuredClone(event.proposal));
next.latestProposalId = event.proposal.proposalId;
break;
}
case 'plan_revision_requested':
proposalById(next, event.proposalId).status = 'stale';
break;
case 'plan_abandoned':
proposalById(next, event.proposalId).status = 'stale';
break;
case 'plan_approved': {
const proposal = proposalById(next, event.proposalId);
proposal.status = 'approved';
if (proposal.sourceExecutionId) {
const sourceExecution = executionById(next, proposal.sourceExecutionId);
sourceExecution.status = 'cancelled';
sourceExecution.updatedAt = event.ts;
sourceExecution.cancelledAt = event.ts;
sourceExecution.cancelReason = `Replanned by proposal ${proposal.proposalId}`;
delete sourceExecution.interruptedAt;
delete sourceExecution.interruptionReason;
}
next.executions.push(structuredClone(event.execution));
next.activeExecutionId = event.execution.executionId;
break;
}
case 'plan_progress_updated': {
const execution = executionById(next, event.executionId);
execution.steps = structuredClone(event.steps);
execution.updatedAt = event.ts;
break;
}
case 'plan_execution_completed': {
const execution = executionById(next, event.executionId);
execution.steps = structuredClone(event.steps);
execution.status = 'completed';
execution.updatedAt = event.ts;
execution.completedAt = event.ts;
if (next.activeExecutionId === execution.executionId) delete next.activeExecutionId;
break;
}
case 'plan_execution_cancelled': {
const execution = executionById(next, event.executionId);
execution.status = 'cancelled';
execution.updatedAt = event.ts;
execution.cancelledAt = event.ts;
execution.cancelReason = event.reason;
if (next.activeExecutionId === execution.executionId) delete next.activeExecutionId;
break;
}
case 'plan_execution_interrupted': {
const execution = executionById(next, event.executionId);
execution.status = 'interrupted';
execution.updatedAt = event.ts;
execution.interruptedAt = event.ts;
execution.interruptionReason = event.reason;
if (next.activeExecutionId === execution.executionId) delete next.activeExecutionId;
break;
}
case 'plan_execution_resumed': {
const execution = executionById(next, event.executionId);
execution.status = 'active';
execution.updatedAt = event.ts;
delete execution.interruptedAt;
delete execution.interruptionReason;
next.activeExecutionId = execution.executionId;
break;
}
}
return next;
}
function mergeExecutionSteps(
execution: PlanExecution,
updates: UpdatePlanExecutionInput['steps'],
now: number,
): PlanExecutionStep[] {
if (updates.length !== execution.steps.length) {
throw new PlanConflictError('update_plan must include every execution step');
}
const byId = new Map(updates.map((step) => [step.id, step]));
if (byId.size !== updates.length) throw new PlanConflictError('Plan step ids must be unique');
const merged = execution.steps.map((step) => {
const update = byId.get(step.id);
if (!update) throw new PlanConflictError(`Plan step ${step.id} is missing`);
if (
(step.status === 'completed' || step.status === 'skipped') &&
update.status !== step.status
) {
throw new PlanConflictError(`Terminal plan step ${step.id} cannot be reopened`);
}
return {
...structuredClone(step),
status: update.status,
...(optionalText(update.note) ? { note: optionalText(update.note) } : {}),
updatedAt: now,
};
});
if (merged.filter((step) => step.status === 'in_progress').length > 1) {
throw new PlanConflictError('Only one plan step may be in progress');
}
return merged;
}
function normalizeDefinitions(steps: PlanStepDefinition[]): PlanStepDefinition[] {
if (!Array.isArray(steps) || steps.length === 0 || steps.length > 50) {
throw new PlanConflictError('A plan must contain between 1 and 50 steps');
}
const normalized = steps.map((step, index) => ({
id: optionalText(step.id) ?? `step-${index + 1}`,
title: requiredPlainText(step.title, 'Plan step title', 30),
description: requiredPlainText(step.description, 'Plan step description'),
...(step.files && step.files.length > 0
? { files: step.files.map((file) => requiredText(file, 'Plan step file')) }
: {}),
...(step.complexity ? { complexity: step.complexity } : {}),
}));
if (new Set(normalized.map((step) => step.id)).size !== normalized.length) {
throw new PlanConflictError('Plan step ids must be unique');
}
return normalized;
}
function proposalById(state: PlanSessionState, proposalId: string): PlanProposal {
const proposal = state.proposals.find((candidate) => candidate.proposalId === proposalId);
if (!proposal) throw new PlanConflictError(`Unknown plan proposal: ${proposalId}`);
return proposal;
}
function executionById(state: PlanSessionState, executionId: string): PlanExecution {
const execution = state.executions.find((candidate) => candidate.executionId === executionId);
if (!execution) throw new PlanConflictError(`Unknown plan execution: ${executionId}`);
return execution;
}
function requireActiveExecution(state: PlanSessionState, executionId: string): PlanExecution {
if (state.activeExecutionId !== executionId) {
throw new PlanConflictError('The plan tool is bound to a stale execution');
}
const execution = executionById(state, executionId);
if (execution.status !== 'active') {
throw new PlanConflictError('Plan execution is not active');
}
return execution;
}
function requireCancellableExecution(state: PlanSessionState, executionId: string): PlanExecution {
const execution = executionById(state, executionId);
if (execution.status !== 'active' && execution.status !== 'interrupted') {
throw new PlanConflictError('Plan execution cannot be cancelled');
}
if (execution.status === 'active' && state.activeExecutionId !== executionId) {
throw new PlanConflictError('The plan tool is bound to a stale execution');
}
return execution;
}
function requiredText(value: string, label: string): string {
const normalized = value.trim();
if (!normalized) throw new PlanConflictError(`${label} cannot be empty`);
return normalized;
}
function requiredPlainText(value: string, label: string, maxLength?: number): string {
const normalized = requiredText(value, label);
if (maxLength !== undefined && normalized.length > maxLength) {
throw new PlanConflictError(`${label} must be ${maxLength} characters or fewer`);
}
if (
/(^|\n)\s{0,3}(?:#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|~~~)|!?(?:\[[^\]\n]+\]\([^)\n]+\))|(?:\*\*|__|`)/.test(
normalized,
)
) {
throw new PlanConflictError(`${label} must be plain text without Markdown formatting`);
}
return normalized;
}
function optionalText(value: string | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
function assertSafeId(value: string): void {
if (!SAFE_ID_PATTERN.test(value)) throw new Error('Invalid session id');
}
function decodePlanEvent(value: unknown, sessionId: string): PlanEvent {
if (!value || typeof value !== 'object') throw new Error('Plan event must be an object');
const event = value as Partial<PlanEvent>;
if (
typeof event.id !== 'string' ||
event.sessionId !== sessionId ||
typeof event.ts !== 'number' ||
!Number.isFinite(event.ts) ||
typeof event.storeVersion !== 'number' ||
!Number.isSafeInteger(event.storeVersion) ||
event.storeVersion < 1 ||
![
'plan_submitted',
'plan_revision_requested',
'plan_abandoned',
'plan_approved',
'plan_progress_updated',
'plan_execution_completed',
'plan_execution_cancelled',
'plan_execution_interrupted',
'plan_execution_resumed',
].includes(String(event.type))
) {
throw new Error('Invalid Plan event envelope');
}
return value as PlanEvent;
}