blob: 1cdfa1bbe4c92eb20fb76d329244f341723e28e6 [file]
import type {
AgentGraphIntentClaimRequest,
AgentGraphIntentClaimResult,
AgentGraphIntentClaimStore,
} from './agent-graph-control.js';
import type { AgentGraphTopologyStore } from './agent-graph-topology.js';
export const AGENT_GRAPH_SCHEDULE_UPDATE_SCHEMA_VERSION = 1 as const;
export const AGENT_GRAPH_SCHEDULE_MAX_ADD_WORK = 20;
export const AGENT_GRAPH_SCHEDULE_MAX_STOP = 20;
export const AGENT_GRAPH_SCHEDULE_MAX_INPUT_IDS = 64;
export const AGENT_GRAPH_SCHEDULE_MAX_RESULT_IDS = 64;
export const AGENT_GRAPH_SCHEDULE_MAX_INSTRUCTION_CHARS = 60_000;
export const AGENT_GRAPH_SCHEDULE_MAX_REASON_CHARS = 4_000;
export interface AgentGraphScheduleUpdateSource {
sessionId: string;
runId: string;
turnId: string;
toolCallId: string;
}
export type AgentGraphWorkTarget =
| {
kind: 'agent';
agentId: string;
}
| {
kind: 'operator';
operatorId: string;
};
export interface AgentGraphScheduledWork {
workId: string;
target: AgentGraphWorkTarget;
instruction: string;
inputIds: string[];
replaces?: string;
}
export interface AgentGraphStoppedTarget {
targetId: string;
reason: string;
}
export interface AgentGraphScheduleFinish {
resultIds: string[];
reason: string;
}
/**
* One main-agent schedule edit after the compact public tool input has been
* normalized and assigned durable identities.
*/
export interface AgentGraphScheduleUpdateRequest {
schemaVersion: typeof AGENT_GRAPH_SCHEDULE_UPDATE_SCHEMA_VERSION;
updateId: string;
updateFingerprint: string;
graphId: string;
source: AgentGraphScheduleUpdateSource;
addWork: AgentGraphScheduledWork[];
stop: AgentGraphStoppedTarget[];
finish?: AgentGraphScheduleFinish;
}
export interface AgentGraphScheduleUpdate extends AgentGraphScheduleUpdateRequest {
revision: number;
committedAt: number;
}
export interface AgentGraphScheduleUpdateResult {
update: AgentGraphScheduleUpdate;
created: boolean;
}
export interface AgentGraphScheduleStore {
commitAgentGraphScheduleUpdate(
request: AgentGraphScheduleUpdateRequest,
): Promise<AgentGraphScheduleUpdateResult>;
listAgentGraphScheduleUpdates(graphId: string): Promise<AgentGraphScheduleUpdate[]>;
}
export type AgentGraphIntentAdmissionState = 'claimed' | 'executing' | 'cancelled';
export interface AgentGraphIntentAdmissionTransition {
state: AgentGraphIntentAdmissionState;
previousState: AgentGraphIntentAdmissionState;
changed: boolean;
}
/**
* Raised when a reconciler tries to admit work from an observation that is no
* longer the current durable schedule revision.
*/
export class AgentGraphScheduleRevisionConflictError extends Error {
readonly name = 'AgentGraphScheduleRevisionConflictError';
constructor(
readonly graphId: string,
readonly expectedRevision: number,
readonly currentRevision: number,
) {
super(
`Agent graph schedule ${graphId} revision changed from ${expectedRevision} to ${currentRevision}`,
);
}
}
/**
* Raised when a reconciler attempts a fresh admission after terminal closure.
* Existing claims remain recoverable after closure.
*/
export class AgentGraphScheduleClosedError extends Error {
readonly name = 'AgentGraphScheduleClosedError';
constructor(readonly graphId: string) {
super(`Agent graph schedule ${graphId} is already finished`);
}
}
/**
* SQLite-backed control-plane boundary used by the schedule reconciler.
*
* The conditional claim linearizes supervisor stop/finish updates against new
* Runtime admission without making the Agent runtime understand graph state.
*/
export interface AgentGraphScheduleControlStore
extends AgentGraphScheduleStore,
AgentGraphIntentClaimStore,
AgentGraphTopologyStore {
claimAgentGraphIntentAtScheduleRevision(
request: AgentGraphIntentClaimRequest,
expectedRevision: number,
): Promise<AgentGraphIntentClaimResult>;
beginAgentGraphIntentExecutionAtScheduleRevision(
graphId: string,
intentId: string,
expectedRevision: number,
): Promise<AgentGraphIntentAdmissionTransition>;
cancelAgentGraphIntentExecution(
graphId: string,
intentId: string,
reason: string,
): Promise<AgentGraphIntentAdmissionTransition>;
}
export function isAgentGraphScheduleUpdateRequest(
value: unknown,
): value is AgentGraphScheduleUpdateRequest {
if (
!isExactRecord(value, [
'schemaVersion',
'updateId',
'updateFingerprint',
'graphId',
'source',
'addWork',
'stop',
...(hasOwn(value, 'finish') ? ['finish'] : []),
])
) {
return false;
}
const request = value as unknown as AgentGraphScheduleUpdateRequest;
return (
request.schemaVersion === AGENT_GRAPH_SCHEDULE_UPDATE_SCHEMA_VERSION &&
/^graph_update_[a-f0-9]{32}$/.test(request.updateId) &&
isSha256Fingerprint(request.updateFingerprint) &&
isOpaqueIdentity(request.graphId) &&
isScheduleSource(request.source) &&
Array.isArray(request.addWork) &&
request.addWork.length <= AGENT_GRAPH_SCHEDULE_MAX_ADD_WORK &&
request.addWork.every(isScheduledWork) &&
unique(request.addWork.map((work) => work.workId)) &&
Array.isArray(request.stop) &&
request.stop.length <= AGENT_GRAPH_SCHEDULE_MAX_STOP &&
request.stop.every(isStoppedTarget) &&
unique(request.stop.map((stopped) => stopped.targetId)) &&
(request.finish === undefined || isScheduleFinish(request.finish)) &&
request.addWork.length + request.stop.length + (request.finish ? 1 : 0) > 0 &&
!(request.finish && request.addWork.length > 0)
);
}
export function isAgentGraphScheduleUpdate(value: unknown): value is AgentGraphScheduleUpdate {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!hasOwn(value, 'revision') ||
!hasOwn(value, 'committedAt')
) {
return false;
}
const { revision, committedAt, ...request } = value as Record<string, unknown>;
return (
isAgentGraphScheduleUpdateRequest(request) &&
isPositiveSafeInteger(revision) &&
isNonNegativeSafeInteger(committedAt)
);
}
export function assertAgentGraphScheduleUpdateRequest(
value: unknown,
): asserts value is AgentGraphScheduleUpdateRequest {
if (!isAgentGraphScheduleUpdateRequest(value)) {
throw new Error('Invalid agent graph schedule update request');
}
}
export function decodeAgentGraphScheduleUpdate(value: unknown): AgentGraphScheduleUpdate {
if (!isAgentGraphScheduleUpdate(value)) {
throw new Error('Invalid agent graph schedule update');
}
return {
schemaVersion: value.schemaVersion,
updateId: value.updateId,
updateFingerprint: value.updateFingerprint,
graphId: value.graphId,
source: { ...value.source },
addWork: value.addWork.map((work) => ({
workId: work.workId,
target: { ...work.target },
instruction: work.instruction,
inputIds: [...work.inputIds],
...(work.replaces ? { replaces: work.replaces } : {}),
})),
stop: value.stop.map((stopped) => ({ ...stopped })),
...(value.finish
? {
finish: {
resultIds: [...value.finish.resultIds],
reason: value.finish.reason,
},
}
: {}),
revision: value.revision,
committedAt: value.committedAt,
};
}
function isScheduleSource(value: unknown): value is AgentGraphScheduleUpdateSource {
return (
isExactRecord(value, ['sessionId', 'runId', 'turnId', 'toolCallId']) &&
isOpaqueIdentity(value.sessionId) &&
isOpaqueIdentity(value.runId) &&
isOpaqueIdentity(value.turnId) &&
isOpaqueIdentity(value.toolCallId)
);
}
function isScheduledWork(value: unknown): value is AgentGraphScheduledWork {
if (
!isExactRecord(value, [
'workId',
'target',
'instruction',
'inputIds',
...(hasOwn(value, 'replaces') ? ['replaces'] : []),
])
) {
return false;
}
return (
typeof value.workId === 'string' &&
/^graph_work_[a-f0-9]{32}$/.test(value.workId) &&
isWorkTarget(value.target) &&
isBoundedText(value.instruction, AGENT_GRAPH_SCHEDULE_MAX_INSTRUCTION_CHARS) &&
Array.isArray(value.inputIds) &&
value.inputIds.length <= AGENT_GRAPH_SCHEDULE_MAX_INPUT_IDS &&
value.inputIds.every(isOpaqueIdentity) &&
unique(value.inputIds) &&
(value.replaces === undefined || isOpaqueIdentity(value.replaces))
);
}
function isWorkTarget(value: unknown): value is AgentGraphWorkTarget {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
if (
isExactRecord(value, ['kind', 'agentId']) &&
value.kind === 'agent' &&
isOpaqueIdentity(value.agentId)
) {
return true;
}
return (
isExactRecord(value, ['kind', 'operatorId']) &&
value.kind === 'operator' &&
isOpaqueIdentity(value.operatorId)
);
}
function isStoppedTarget(value: unknown): value is AgentGraphStoppedTarget {
return (
isExactRecord(value, ['targetId', 'reason']) &&
isOpaqueIdentity(value.targetId) &&
isBoundedText(value.reason, AGENT_GRAPH_SCHEDULE_MAX_REASON_CHARS)
);
}
function isScheduleFinish(value: unknown): value is AgentGraphScheduleFinish {
return (
isExactRecord(value, ['resultIds', 'reason']) &&
Array.isArray(value.resultIds) &&
value.resultIds.length > 0 &&
value.resultIds.length <= AGENT_GRAPH_SCHEDULE_MAX_RESULT_IDS &&
value.resultIds.every(isOpaqueIdentity) &&
unique(value.resultIds) &&
isBoundedText(value.reason, AGENT_GRAPH_SCHEDULE_MAX_REASON_CHARS)
);
}
function isBoundedText(value: unknown, maxChars: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= maxChars &&
value.trim() === value &&
!/[\u0000\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
);
}
function isOpaqueIdentity(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= 256 &&
value.trim() === value &&
!/[\u0000-\u001f\u007f]/.test(value)
);
}
function isSha256Fingerprint(value: unknown): value is string {
return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value);
}
function isPositiveSafeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
}
function isNonNegativeSafeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
function unique(values: readonly string[]): boolean {
return new Set(values).size === values.length;
}
function hasOwn(value: unknown, key: string): boolean {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.prototype.hasOwnProperty.call(value, key)
);
}
function isExactRecord(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}