blob: 8259704b89b64fec1a488731c32716895e40f917 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { JsonArrayPageBudget } from './json-array-page-budget.js';
import { createHash } from 'node:crypto';
import {
PlanConflictError,
planUserControlMutationInput,
type PlanEvent,
type PlanMutationResult,
} from '@maka/core/plan';
import type { SessionManager } from '@maka/runtime/session-manager';
import {
isSessionNotFoundError,
type ExecutionSessionWriter,
} from '@maka/storage/execution-stores';
import {
authenticateInteractivePlanStoreWriter,
type InteractivePlanStoreWriter,
} from '@maka/storage/plan-authority';
import {
PLAN_PAGE_MAX_ITEMS,
PLAN_RESULT_MAX_BYTES,
type OperationOutcome,
type PlanControlInput,
type PlanControlResult,
type PlanTurnStartInput,
type PlanProjectionItem,
type PlanQueryInput,
type PlanQueryResult,
planTurnControlInput,
} from '../protocol/index.js';
import type { ConnectionContext, PlanOperationHandlerMap } from './operation-dispatcher.js';
import type { RootTurnCoordinator } from './root-turn-coordinator.js';
import { SessionAdmissionGate, type SessionAdmissionLease } from './session-admission-gate.js';
type PlanRuntime = Pick<
SessionManager,
| 'requestPlanRevision'
| 'abandonPlanProposal'
| 'approvePlan'
| 'resumePlanExecution'
| 'cancelPlanExecution'
>;
type AdmittedPlanControlRequest =
| { readonly kind: 'ordinary'; readonly input: PlanControlInput }
| { readonly kind: 'plan_turn'; readonly input: PlanTurnStartInput };
export interface HostPlanCoordinatorInput {
readonly store: InteractivePlanStoreWriter;
readonly sessions: Pick<ExecutionSessionWriter, 'readHeaderSnapshot'>;
readonly runtime: PlanRuntime;
readonly sessionAdmission: SessionAdmissionGate;
readonly isSessionActive: (sessionId: string) => boolean;
readonly refreshContinuity: (sessionId: string, lease: SessionAdmissionLease) => Promise<void>;
readonly onProjectionChanged: (sessionId: string) => void;
readonly requestDrain: () => void;
readonly root: Pick<RootTurnCoordinator, 'startHostedExternalTransition'>;
}
/** Host-owned Plan query and user-control boundary. */
export class HostPlanCoordinator {
readonly handlers: PlanOperationHandlerMap = {
'plan.query': (input) => this.#sessionAdmission.run(input.sessionId, () => this.#query(input)),
'plan.control': (input) =>
this.#sessionAdmission.run(input.sessionId, (lease) =>
this.#control({ kind: 'ordinary', input }, lease),
),
'plan.turn.start': (input, context) => this.#startTurn(input, context),
};
readonly #store: InteractivePlanStoreWriter;
readonly #sessions: HostPlanCoordinatorInput['sessions'];
readonly #runtime: PlanRuntime;
readonly #sessionAdmission: SessionAdmissionGate;
readonly #isSessionActive: (sessionId: string) => boolean;
readonly #refreshContinuity: HostPlanCoordinatorInput['refreshContinuity'];
readonly #onProjectionChanged: HostPlanCoordinatorInput['onProjectionChanged'];
readonly #requestDrain: () => void;
readonly #root: HostPlanCoordinatorInput['root'];
constructor(input: HostPlanCoordinatorInput) {
this.#store = authenticateInteractivePlanStoreWriter(input.store);
this.#sessions = input.sessions;
this.#runtime = input.runtime;
this.#sessionAdmission = input.sessionAdmission;
this.#isSessionActive = input.isSessionActive;
this.#refreshContinuity = input.refreshContinuity;
this.#onProjectionChanged = input.onProjectionChanged;
this.#requestDrain = input.requestDrain;
this.#root = input.root;
}
async #startTurn(
input: PlanTurnStartInput,
context: ConnectionContext,
): Promise<OperationOutcome<'plan.turn.start'>> {
let plan: PlanControlResult | undefined;
let planFailure: Extract<OperationOutcome<'plan.control'>, { ok: false }> | undefined;
const turn = await this.#root.startHostedExternalTransition(
{
sessionId: input.sessionId,
turnId: input.turnId,
inputDigest: planTurnInputDigest(input),
archivedMessage: 'Cannot start Plan execution in an archived Session',
prepareContent: async (lease) => {
const outcome = await this.#control({ kind: 'plan_turn', input }, lease);
if (!outcome.ok) {
planFailure = outcome;
return {
kind: 'rejected',
outcome: {
ok: false,
error: { code: 'operation_conflict', message: outcome.error.message },
},
};
}
plan = outcome.result;
return { kind: 'ready', content: { text: planTurnPrompt(input, outcome.result) } };
},
},
context,
);
if (planFailure) return { ok: false, error: planFailure.error };
if (!turn.ok) return { ok: false, error: turn.error };
if (!plan) {
this.#requestDrain();
return {
ok: false,
error: { code: 'internal_failure', message: 'Plan Turn transition outcome is unknown' },
};
}
return { ok: true, result: { plan, turn: turn.result } };
}
async #query(input: PlanQueryInput): Promise<OperationOutcome<'plan.query'>> {
const unavailable = await this.#assertSessionAvailable(input.sessionId);
if (unavailable) return failure(unavailable.code, unavailable.message);
try {
const state = await this.#store.readState(input.sessionId);
if (input.kind === 'list_continue' && input.storeVersion !== state.storeVersion) {
return success({
kind: 'revision_changed',
expected: input.storeVersion,
actual: state.storeVersion,
});
}
let offset = 0;
if (input.kind === 'list_continue') {
try {
offset = decodeCursor(input.cursor);
} catch {
return failure('invalid_request', 'Plan cursor is invalid');
}
}
const items: PlanProjectionItem[] = [
...state.proposals.map((proposal) => ({ kind: 'proposal' as const, proposal })),
...state.executions.map((execution) => ({ kind: 'execution' as const, execution })),
];
if (offset > items.length) {
return failure('invalid_request', 'Plan cursor is outside the current projection');
}
return success(
fitPage(
{
sessionId: input.sessionId,
storeVersion: state.storeVersion,
latestProposalId: state.latestProposalId ?? null,
activeExecutionId: state.activeExecutionId ?? null,
},
items,
offset,
),
);
} catch (error) {
if (isSessionNotFoundError(error)) return failure('not_found', 'Session does not exist');
return failure('internal_failure', 'Plan projection is unavailable');
}
}
async #control(
request: AdmittedPlanControlRequest,
lease: SessionAdmissionLease,
): Promise<OperationOutcome<'plan.control'>> {
const input = request.kind === 'ordinary' ? request.input : planTurnControlInput(request.input);
let replay = false;
try {
replay =
(await this.#store.readOperationReceipt(
input.sessionId,
input.operationId,
planUserControlMutationInput(input),
)) !== undefined;
} catch (error) {
return this.#controlFailure(error);
}
if (!replay) {
const unavailable = await this.#assertSessionAvailable(input.sessionId);
if (unavailable) return controlAvailabilityFailure(unavailable.code, unavailable.message);
if (request.kind === 'ordinary' && this.#isSessionActive(input.sessionId)) {
return controlFailure('session_busy', 'Session has an active root Turn');
}
}
try {
const result = await this.#applyControl(input);
this.#onProjectionChanged(input.sessionId);
await this.#refreshContinuity(input.sessionId, lease);
return { ok: true, result: projectControlResult(result) };
} catch (error) {
return this.#controlFailure(error);
}
}
#controlFailure(error: unknown): OperationOutcome<'plan.control'> {
if (isSessionNotFoundError(error)) {
return controlFailure('not_found', 'Session does not exist');
}
if (error instanceof PlanConflictError) {
return controlFailure('operation_conflict', error.message);
}
this.#requestDrain();
return controlFailure('persistence_failed', 'Plan control outcome is unknown');
}
#applyControl(input: PlanControlInput): Promise<PlanMutationResult> {
switch (input.kind) {
case 'request_revision':
return this.#runtime.requestPlanRevision(
input.sessionId,
input.proposalId,
input.operationId,
);
case 'abandon_proposal':
return this.#runtime.abandonPlanProposal(
input.sessionId,
input.proposalId,
input.operationId,
);
case 'approve_proposal':
return this.#runtime.approvePlan({
sessionId: input.sessionId,
proposalId: input.proposalId,
expectedRevision: input.expectedRevision,
expectedStoreVersion: input.expectedStoreVersion,
operationId: input.operationId,
});
case 'resume_execution':
return this.#runtime.resumePlanExecution(
input.sessionId,
input.executionId,
input.operationId,
);
case 'cancel_execution':
return this.#runtime.cancelPlanExecution(
input.sessionId,
input.executionId,
input.operationId,
);
}
}
async #assertSessionAvailable(sessionId: string): Promise<
| {
code: 'not_found' | 'session_archived' | 'internal_failure';
message: string;
}
| undefined
> {
try {
const header = await this.#sessions.readHeaderSnapshot(sessionId);
if (header.isArchived) {
return { code: 'session_archived', message: 'Session is archived' };
}
return undefined;
} catch (error) {
if (isSessionNotFoundError(error)) {
return { code: 'not_found', message: 'Session does not exist' };
}
return { code: 'internal_failure', message: 'Session authority is unavailable' };
}
}
}
function fitPage(
header: {
readonly sessionId: string;
readonly storeVersion: number;
readonly latestProposalId: string | null;
readonly activeExecutionId: string | null;
},
allItems: readonly PlanProjectionItem[],
offset: number,
): Extract<PlanQueryResult, { kind: 'page' }> {
const items: PlanProjectionItem[] = [];
const budget = new JsonArrayPageBudget(PLAN_RESULT_MAX_BYTES, {
kind: 'page',
...header,
items: [],
nextCursor: null,
});
const limit = Math.min(allItems.length, offset + PLAN_PAGE_MAX_ITEMS);
for (let index = offset; index < limit; index += 1) {
const item = structuredClone(allItems[index]!);
const nextOffset = offset + items.length + 1;
if (!budget.tryAppend(item, nextOffset < allItems.length ? String(nextOffset) : null)) {
if (items.length === 0) throw new Error('Persisted Plan item exceeds its wire invariant');
break;
}
items.push(item);
}
return planPage(header, items, offset + items.length, allItems.length);
}
function planPage(
header: Parameters<typeof fitPage>[0],
items: readonly PlanProjectionItem[],
nextOffset: number,
total: number,
): Extract<PlanQueryResult, { kind: 'page' }> {
return {
kind: 'page',
...header,
items,
nextCursor: nextOffset < total ? String(nextOffset) : null,
};
}
function decodeCursor(cursor: string): number {
if (!/^\d+$/.test(cursor)) throw new PlanConflictError('Invalid Plan cursor');
const offset = Number(cursor);
if (!Number.isSafeInteger(offset)) throw new PlanConflictError('Invalid Plan cursor');
return offset;
}
function projectControlResult(result: PlanMutationResult): PlanControlResult {
return projectControlEvent(result.event);
}
function projectControlEvent(event: PlanEvent): PlanControlResult {
return {
sessionId: event.sessionId,
storeVersion: event.storeVersion,
eventType: event.type,
proposalId: proposalId(event),
executionId: executionId(event),
};
}
function proposalId(event: PlanEvent): string | null {
if (event.type === 'plan_submitted') return event.proposal.proposalId;
return 'proposalId' in event ? event.proposalId : null;
}
function executionId(event: PlanEvent): string | null {
if (event.type === 'plan_approved') return event.execution.executionId;
return 'executionId' in event ? event.executionId : null;
}
function success(result: PlanQueryResult): OperationOutcome<'plan.query'> {
return { ok: true, result };
}
function failure(
code: 'not_found' | 'session_archived' | 'invalid_request' | 'internal_failure',
message: string,
): OperationOutcome<'plan.query'> {
return { ok: false, error: { code, message } };
}
function controlFailure(
code: 'not_found' | 'session_busy' | 'operation_conflict' | 'persistence_failed',
message: string,
): OperationOutcome<'plan.control'> {
return { ok: false, error: { code, message } };
}
function controlAvailabilityFailure(
code: 'not_found' | 'session_archived' | 'internal_failure',
message: string,
): OperationOutcome<'plan.control'> {
return { ok: false, error: { code, message } };
}
function planTurnInputDigest(input: PlanTurnStartInput): `sha256:${string}` {
const canonical =
input.kind === 'approve_proposal'
? [
input.kind,
input.sessionId,
input.proposalId,
input.expectedRevision,
input.expectedStoreVersion,
]
: [input.kind, input.sessionId, input.executionId];
return `sha256:${createHash('sha256').update(JSON.stringify(canonical)).digest('hex')}`;
}
function planTurnPrompt(input: PlanTurnStartInput, result: PlanControlResult): string {
if (input.kind === 'approve_proposal') {
if (result.executionId === null) {
throw new PlanConflictError('Plan approval did not create an execution');
}
return `Execute the approved plan execution ${result.executionId}.`;
}
return `Resume the approved plan execution ${input.executionId}.`;
}