diff --git a/Cargo.lock b/Cargo.lock index 9a55062c044..a2a2c1850a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7875,8 +7875,11 @@ dependencies = [ name = "maka-event-log" version = "0.2.0" dependencies = [ + "cap-fs-ext", + "cap-std", "futures-util", "hex", + "ignore", "libc", "maka-agent-facts", "maka-agent-ports", diff --git a/packages/plugin-sdk/src/contracts.ts b/packages/plugin-sdk/src/contracts.ts index ad111a62d38..886c8d0656a 100644 --- a/packages/plugin-sdk/src/contracts.ts +++ b/packages/plugin-sdk/src/contracts.ts @@ -1946,1133 +1946,1307 @@ export const contracts = { }, resources: {}, }, - compact: { - mode: 'mutation', + checkpointReceipt: { + mode: 'query', input: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentCompact', + title: 'AgentCheckpointReceiptRead', type: 'object', properties: { - sessionId: { - type: 'string', - minLength: 1, - }, - model: { - $ref: '#/$defs/ModelSelection', - }, - thinkingLevel: { - anyOf: [ - { - $ref: '#/$defs/ThinkingLevel', - }, - { - type: 'null', - }, - ], + reference: { + $ref: '#/$defs/RecoveryRef', }, - prompt: { + input: { description: - "One invocation only; omission uses the Session's effective preference.", - type: ['string', 'null'], + 'The exact original input. Changed input cannot select another publication.', + $ref: '#/$defs/AgentCheckpointPublish', }, }, additionalProperties: false, - required: ['sessionId', 'model'], + required: ['reference', 'input'], $defs: { - ModelSelection: { - description: - 'Stable model identity, resolved from current connection settings at admission.', + RecoveryRef: { type: 'object', properties: { - connectionId: { + source: { + $ref: '#/$defs/DeploymentRef', + }, + namespace: { + $ref: '#/$defs/InstanceId', + }, + export: { type: 'string', - minLength: 1, }, - modelId: { + method: { type: 'string', - minLength: 1, + }, + operation: { + type: 'string', + format: 'uuid', + }, + fingerprint: { + type: 'string', + }, + contract: { + $ref: '#/$defs/RecoveryContract', + }, + recovery: { + $ref: '#/$defs/RecoveryProtocol', }, }, - required: ['connectionId', 'modelId'], - }, - ThinkingLevel: { - type: 'string', - enum: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + additionalProperties: false, + required: [ + 'source', + 'namespace', + 'export', + 'method', + 'operation', + 'fingerprint', + 'contract', + 'recovery', + ], }, - }, - }, - output: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentCompactResult', - oneOf: [ - { + DeploymentRef: { type: 'object', properties: { - kind: { + stateDomain: { type: 'string', - const: 'started', }, - execution: { - $ref: '#/$defs/AgentExecution', + deployment: { + type: 'string', + format: 'uuid', + }, + revision: { + type: 'integer', + format: 'uint64', + minimum: 0, }, }, additionalProperties: false, - required: ['kind', 'execution'], + required: ['stateDomain', 'deployment', 'revision'], }, - { + InstanceId: { + description: + 'Hierarchical, stable source keys. Display aliases never become data identities.', + type: 'array', + items: { + type: 'string', + format: 'uuid', + }, + }, + RecoveryContract: { + description: + 'Contract identity for a journal lookup. The original request retains the\ncomplete contract; a receipt carries only its digest.', type: 'object', properties: { - kind: { + name: { type: 'string', - const: 'rejected', }, - reason: { - $ref: '#/$defs/AgentCompactRejection', + version: { + type: 'integer', + format: 'uint32', + minimum: 0, + }, + transport: { + $ref: '#/$defs/Transport', + }, + digest: { + type: 'string', }, }, additionalProperties: false, - required: ['kind', 'reason'], + required: ['name', 'version', 'transport', 'digest'], }, - ], - $defs: { - AgentExecution: { - description: - 'Canonical physical execution receipt, independent of the current Session tip.', + Transport: { + type: 'string', + enum: ['local', 'remote'], + }, + RecoveryProtocol: { + type: 'string', + enum: ['journal_v1'], + }, + AgentCheckpointPublish: { type: 'object', properties: { sessionId: { type: 'string', + minLength: 1, }, - turnId: { - type: 'string', - }, - runId: { - type: 'string', + expectedRevision: { + type: ['string', 'null'], + format: 'uuid', }, - invocationId: { + confirmedSubtreeDigest: { + description: + 'Membership confirmation returned by previewHistoryCheckpoint, not a live revision.', type: 'string', + pattern: '^[a-f0-9]{64}$', }, - highWater: { - description: 'Run-local event count, not the root-global transcript cursor.', - type: 'integer', - format: 'uint64', - minimum: 0, - }, - state: { - $ref: '#/$defs/AgentExecutionState', + workspaceConsistency: { + description: + 'Null captures history only. Otherwise the caller explicitly selects the\nWorkspace consistency guarantee; other applications are never fenced.', + anyOf: [ + { + $ref: '#/$defs/AgentCheckpointWorkspaceConsistency', + }, + { + type: 'null', + }, + ], }, }, additionalProperties: false, - required: ['sessionId', 'turnId', 'runId', 'invocationId', 'highWater', 'state'], - }, - AgentExecutionState: { - type: 'string', - enum: [ - 'admitted', - 'running', - 'waiting', - 'paused', - 'completed', - 'failed', - 'cancelled', + required: [ + 'sessionId', + 'expectedRevision', + 'confirmedSubtreeDigest', + 'workspaceConsistency', ], }, - AgentCompactRejection: { - type: 'string', - enum: ['busy', 'closed', 'archived', 'backend_unavailable', 'workspace_changed'], - }, - }, - }, - resources: {}, - }, - compactionPrompt: { - mode: 'query', - input: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentSessionRead', - type: 'object', - properties: { - sessionId: { - type: 'string', - minLength: 1, + AgentCheckpointWorkspaceConsistency: { + oneOf: [ + { + description: + 'The caller confirms all other writers remain paused for the entire copy.', + type: 'string', + const: 'caller_confirmed_quiescent', + }, + { + description: + 'Files may not represent one instant; successful digest checks do not change this.', + type: 'string', + const: 'unverified', + }, + ], }, }, - additionalProperties: false, - required: ['sessionId'], }, output: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentCompactionPrompt', + title: 'AgentCheckpointReceipt', type: 'object', properties: { - revision: { - type: 'integer', - format: 'uint64', - minimum: 1, - maximum: 9007199254740991, - }, - prompt: { - type: ['string', 'null'], - }, - compactionPrompt: { - type: 'string', + operation: { + description: + 'Original Host observation. Domain success never fabricates Host cleanup confirmation.', + anyOf: [ + { + $ref: '#/$defs/OperationObservation', + }, + { + type: 'null', + }, + ], }, - defaultCompactionPrompt: { - type: 'string', + publication: { + description: + 'Authoritative domain result even when the Host response was lost.\nAbsence is not permission to recapture under a replacement operation.', + anyOf: [ + { + $ref: '#/$defs/AgentCheckpointPublication', + }, + { + type: 'null', + }, + ], }, }, additionalProperties: false, - required: ['revision', 'prompt', 'compactionPrompt', 'defaultCompactionPrompt'], - }, - resources: {}, - }, - computerApps: { - mode: 'query', - input: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentSessionRead', - type: 'object', - properties: { - sessionId: { - type: 'string', - minLength: 1, - }, - }, - additionalProperties: false, - required: ['sessionId'], - }, - output: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentComputerApps', - type: 'object', - properties: { - entries: { - type: 'array', - items: { - $ref: '#/$defs/AgentComputerAppActivityState', - }, - }, - }, - additionalProperties: false, - required: ['entries'], $defs: { - AgentComputerAppActivityState: { + OperationObservation: { type: 'object', properties: { - activity: { - $ref: '#/$defs/ComputerAppActivity', + reference: { + $ref: '#/$defs/RecoveryRef', }, - stopped: { - type: 'boolean', + outcome: { + $ref: '#/$defs/OperationOutcome', + }, + cleanup: { + $ref: '#/$defs/OperationCleanup', }, }, additionalProperties: false, - required: ['activity', 'stopped'], + required: ['reference', 'outcome', 'cleanup'], }, - ComputerAppActivity: { + RecoveryRef: { type: 'object', properties: { - invocation: { - $ref: '#/$defs/Invocation', - }, - operationId: { - type: 'string', - }, - target: { - $ref: '#/$defs/GrantTarget', + source: { + $ref: '#/$defs/DeploymentRef', }, - display: { - $ref: '#/$defs/ComputerAppDisplay', + namespace: { + $ref: '#/$defs/InstanceId', }, - }, - additionalProperties: false, - required: ['invocation', 'operationId', 'target', 'display'], - }, - Invocation: { - type: 'object', - properties: { - session_id: { + export: { type: 'string', }, - turn_id: { + method: { type: 'string', }, - run_id: { + operation: { type: 'string', + format: 'uuid', }, - invocation_id: { + fingerprint: { type: 'string', }, + contract: { + $ref: '#/$defs/RecoveryContract', + }, + recovery: { + $ref: '#/$defs/RecoveryProtocol', + }, }, - required: ['session_id', 'turn_id', 'run_id', 'invocation_id'], + additionalProperties: false, + required: [ + 'source', + 'namespace', + 'export', + 'method', + 'operation', + 'fingerprint', + 'contract', + 'recovery', + ], }, - GrantTarget: { + DeploymentRef: { type: 'object', properties: { - providerId: { - type: 'string', - }, - contractId: { - type: 'string', - }, - serverId: { + stateDomain: { type: 'string', }, - toolName: { + deployment: { type: 'string', + format: 'uuid', }, - capability: { - $ref: '#/$defs/GrantCapability', - }, - scope: { - $ref: '#/$defs/GrantScope', + revision: { + type: 'integer', + format: 'uint64', + minimum: 0, }, }, - required: [ - 'providerId', - 'contractId', - 'serverId', - 'toolName', - 'capability', - 'scope', - ], + additionalProperties: false, + required: ['stateDomain', 'deployment', 'revision'], }, - GrantCapability: { - oneOf: [ - { + InstanceId: { + description: + 'Hierarchical, stable source keys. Display aliases never become data identities.', + type: 'array', + items: { + type: 'string', + format: 'uuid', + }, + }, + RecoveryContract: { + description: + 'Contract identity for a journal lookup. The original request retains the\ncomplete contract; a receipt carries only its digest.', + type: 'object', + properties: { + name: { type: 'string', - enum: ['browser', 'computer_use', 'computer_audio', 'desktop_mcp', 'mcp'], }, - { - description: - 'File download consent for a browser origin, independent of page access.', - type: 'string', - const: 'browser_download', + version: { + type: 'integer', + format: 'uint32', + minimum: 0, }, - { - description: - "Read the browser profile's browsing history, independent of page access.", - type: 'string', - const: 'browser_history', + transport: { + $ref: '#/$defs/Transport', }, - { - description: - 'File upload consent for a browser origin, independent of reads and downloads.', + digest: { type: 'string', - const: 'browser_upload', }, - ], + }, + additionalProperties: false, + required: ['name', 'version', 'transport', 'digest'], }, - GrantScope: { + Transport: { + type: 'string', + enum: ['local', 'remote'], + }, + RecoveryProtocol: { + type: 'string', + enum: ['journal_v1'], + }, + OperationOutcome: { + description: + 'Unresolved means no durable result is known, including after process loss.', oneOf: [ { type: 'object', properties: { kind: { type: 'string', - const: 'browser_origin', - }, - origin: { - type: 'string', + const: 'unresolved', }, }, + required: ['kind'], additionalProperties: false, - required: ['kind', 'origin'], }, { - description: - 'One selected browser profile generation; never an origin or app-wide grant.', type: 'object', properties: { kind: { type: 'string', - const: 'browser_profile', + const: 'returned', }, - browserId: { + value: true, + }, + additionalProperties: false, + required: ['kind', 'value'], + }, + { + type: 'object', + properties: { + kind: { type: 'string', + const: 'unknown', }, - generation: { + diagnostic: { type: 'string', }, }, additionalProperties: false, - required: ['kind', 'browserId', 'generation'], + required: ['kind', 'diagnostic'], }, + ], + }, + OperationCleanup: { + oneOf: [ { type: 'object', properties: { kind: { type: 'string', - const: 'capability', + const: 'unconfirmed', }, }, - additionalProperties: false, required: ['kind'], + additionalProperties: false, }, { type: 'object', properties: { kind: { type: 'string', - const: 'mcp_tool', - }, - serverId: { - type: 'string', - }, - toolName: { - type: 'string', + const: 'confirmed', }, }, + required: ['kind'], additionalProperties: false, - required: ['kind', 'serverId', 'toolName'], }, { type: 'object', properties: { kind: { type: 'string', - const: 'computer_app', - }, - platform: { - $ref: '#/$defs/ComputerAppPlatform', + const: 'failed', }, - applicationId: { + diagnostic: { type: 'string', }, }, additionalProperties: false, - required: ['kind', 'platform', 'applicationId'], + required: ['kind', 'diagnostic'], }, ], }, - ComputerAppPlatform: { - type: 'string', - enum: ['macos', 'windows'], - }, - ComputerAppDisplay: { + AgentCheckpointPublication: { type: 'object', properties: { - name: { - type: 'string', + state: { + $ref: '#/$defs/AgentHistoryCheckpointState', }, - appPath: { - type: 'string', + cleanupPending: { + description: 'Cleanup failure does not undo a successful publication.', + type: 'boolean', }, - risk: { + workspace: { anyOf: [ { - $ref: '#/$defs/ComputerAppRisk', + $ref: '#/$defs/AgentCheckpointWorkspaceNotice', }, { type: 'null', }, ], }, - warning: { - type: ['string', 'null'], - }, }, additionalProperties: false, - required: ['name', 'appPath'], + required: ['state', 'cleanupPending'], }, - ComputerAppRisk: { - type: 'string', - enum: ['low', 'high'], + AgentHistoryCheckpointState: { + oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'capturing', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'prepared', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'published', + }, + head: { + $ref: '#/$defs/AgentHistoryCheckpointHead', + }, + }, + additionalProperties: false, + required: ['kind', 'head'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'conflict', + }, + actual: { + type: ['string', 'null'], + format: 'uuid', + }, + }, + additionalProperties: false, + required: ['kind'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'interrupted', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'unavailable', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + ], + }, + AgentHistoryCheckpointHead: { + type: 'object', + properties: { + revision: { + type: 'string', + format: 'uuid', + }, + manifest: { + $ref: '#/$defs/AgentHistoryCheckpointObject', + }, + }, + additionalProperties: false, + required: ['revision', 'manifest'], + }, + AgentHistoryCheckpointObject: { + type: 'object', + properties: { + digest: { + type: 'string', + pattern: '^[a-f0-9]{64}$', + }, + bytes: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + }, + additionalProperties: false, + required: ['digest', 'bytes'], + }, + AgentCheckpointWorkspaceNotice: { + type: 'object', + properties: { + consistency: { + $ref: '#/$defs/AgentCheckpointWorkspaceConsistency', + }, + warning: { + $ref: '#/$defs/AgentCheckpointWorkspaceWarning', + }, + }, + additionalProperties: false, + required: ['consistency', 'warning'], + }, + AgentCheckpointWorkspaceConsistency: { + oneOf: [ + { + description: + 'The caller confirms all other writers remain paused for the entire copy.', + type: 'string', + const: 'caller_confirmed_quiescent', + }, + { + description: + 'Files may not represent one instant; successful digest checks do not change this.', + type: 'string', + const: 'unverified', + }, + ], + }, + AgentCheckpointWorkspaceWarning: { + oneOf: [ + { + description: + 'Display to users: pause editors, terminals and other writers for the entire\ncapture. Hash verification is not a point-in-time consistency guarantee.', + type: 'string', + const: 'external_writers_not_fenced', + }, + ], }, }, }, resources: {}, }, - context: { - mode: 'query', + compact: { + mode: 'mutation', input: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentContextRead', + title: 'AgentCompact', type: 'object', properties: { sessionId: { type: 'string', + minLength: 1, }, - through: { - description: - 'Null captures the committed head; a supplied fence must not exceed it.', - type: ['integer', 'null'], - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, - }, - }, - additionalProperties: false, - required: ['sessionId', 'through'], - }, - output: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentContextReport', - type: 'object', - properties: { - through: { - type: 'integer', - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, + model: { + $ref: '#/$defs/ModelSelection', }, - request: { - description: - 'Latest Main request made by this Session, excluding copied source requests.', + thinkingLevel: { anyOf: [ { - $ref: '#/$defs/AgentContextRequest', + $ref: '#/$defs/ThinkingLevel', }, { type: 'null', }, ], }, + prompt: { + description: + "One invocation only; omission uses the Session's effective preference.", + type: ['string', 'null'], + }, }, additionalProperties: false, - required: ['through', 'request'], + required: ['sessionId', 'model'], $defs: { - AgentContextRequest: { + ModelSelection: { + description: + 'Stable model identity, resolved from current connection settings at admission.', type: 'object', properties: { - invocationId: { + connectionId: { type: 'string', + minLength: 1, }, - stepId: { + modelId: { type: 'string', + minLength: 1, }, - sequence: { - type: 'integer', - format: 'uint64', - minimum: 1, - maximum: 9007199254740991, - }, - recordedAt: { - description: 'Original request timestamp in milliseconds since the Unix epoch.', - type: 'number', - format: 'double', - minimum: 0, - maximum: 9007199254740991, - }, - sourceThrough: { - description: 'Original input history fence, before this request was recorded.', - type: 'integer', - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, - }, - modelId: { + }, + required: ['connectionId', 'modelId'], + }, + ThinkingLevel: { + type: 'string', + enum: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + }, + }, + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentCompactResult', + oneOf: [ + { + type: 'object', + properties: { + kind: { type: 'string', + const: 'started', }, - contextWindow: { - type: ['integer', 'null'], - format: 'uint64', - minimum: 1, - maximum: 9007199254740991, + execution: { + $ref: '#/$defs/AgentExecution', }, - inputLimit: { - type: ['integer', 'null'], - format: 'uint64', - minimum: 1, - maximum: 9007199254740991, + }, + additionalProperties: false, + required: ['kind', 'execution'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'rejected', }, - state: { - $ref: '#/$defs/AgentContextRequestState', - }, - breakdown: { - description: - 'Original Host prepared-input measurements, not provider wire sizes or exact tokenization.', - anyOf: [ - { - $ref: '#/$defs/PreparedContextBreakdown', - }, - { - type: 'null', - }, - ], - }, - reportedInputTokens: { - description: - 'Provider-reported input tokens from this exact completed step, if available.', - type: ['integer', 'null'], - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, + reason: { + $ref: '#/$defs/AgentCompactRejection', }, }, additionalProperties: false, - required: [ - 'invocationId', - 'stepId', - 'sequence', - 'recordedAt', - 'sourceThrough', - 'modelId', - 'contextWindow', - 'inputLimit', - 'state', - 'breakdown', - 'reportedInputTokens', - ], - }, - AgentContextRequestState: { - type: 'string', - enum: ['requested', 'completed', 'interrupted'], + required: ['kind', 'reason'], }, - PreparedContextBreakdown: { + ], + $defs: { + AgentExecution: { description: - 'Host-prepared adapter input, not provider tokenization or HTTP wire bytes.', + 'Canonical physical execution receipt, independent of the current Session tip.', type: 'object', properties: { - system: { - $ref: '#/$defs/ContextBucket', - }, - user: { - $ref: '#/$defs/ContextBucket', - }, - assistant: { - $ref: '#/$defs/ContextBucket', + sessionId: { + type: 'string', }, - tool: { - description: - 'Messages with the tool role; assistant-embedded results stay in assistant.', - $ref: '#/$defs/ContextBucket', + turnId: { + type: 'string', }, - toolSchemas: { - $ref: '#/$defs/ContextBucket', + runId: { + type: 'string', }, - counts: { - $ref: '#/$defs/ContextCounts', + invocationId: { + type: 'string', }, - }, - additionalProperties: false, - required: ['system', 'user', 'assistant', 'tool', 'toolSchemas', 'counts'], - }, - ContextBucket: { - type: 'object', - properties: { - contentBytes: { - description: - 'UTF-8 text and compact JSON payload bytes counted by the Host heuristic.\nExcludes file encodings, message metadata, request-level provider options\nand synthetic framing overhead. Tool definitions are counted in full.', + highWater: { + description: 'Run-local event count, not the root-global transcript cursor.', type: 'integer', format: 'uint64', minimum: 0, - maximum: 9007199254740991, }, - estimatedTokens: { - description: - 'Heuristic: separately rounded text/JSON bytes / 3, plus message/tool\nframing and 1024 per media part. Never provider-measured tokens.', - type: 'integer', - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, + state: { + $ref: '#/$defs/AgentExecutionState', }, }, additionalProperties: false, - required: ['contentBytes', 'estimatedTokens'], + required: ['sessionId', 'turnId', 'runId', 'invocationId', 'highWater', 'state'], }, - ContextCounts: { - type: 'object', - properties: { - messages: { - type: 'integer', - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, - }, - media: { - type: 'integer', - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, - }, - toolCalls: { - type: 'integer', - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, - }, - toolResults: { - description: 'Result parts across both tool and assistant roles.', - type: 'integer', - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, - }, - tools: { - type: 'integer', - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, - }, - }, - additionalProperties: false, - required: ['messages', 'media', 'toolCalls', 'toolResults', 'tools'], + AgentExecutionState: { + type: 'string', + enum: [ + 'admitted', + 'running', + 'waiting', + 'paused', + 'completed', + 'failed', + 'cancelled', + ], + }, + AgentCompactRejection: { + type: 'string', + enum: ['busy', 'closed', 'archived', 'backend_unavailable', 'workspace_changed'], }, }, }, resources: {}, }, - create: { - mode: 'mutation', + compactionPrompt: { + mode: 'query', input: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentCreate', + title: 'AgentSessionRead', type: 'object', properties: { - backend: { - description: 'Initial next-turn preference; admission resolves it again.', - anyOf: [ - { - $ref: '#/$defs/AgentBackendPreference', - }, - { - type: 'null', - }, - ], + sessionId: { + type: 'string', + minLength: 1, }, - automaticTitle: { - description: - 'Treat title as a placeholder for one title derived from the first user message.', - type: 'boolean', - default: false, + }, + additionalProperties: false, + required: ['sessionId'], + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentCompactionPrompt', + type: 'object', + properties: { + revision: { + type: 'integer', + format: 'uint64', + minimum: 1, + maximum: 9007199254740991, }, - tools: { - description: - "Immutable narrowing of this Agent's installed tools. None uses the installation;\nan empty list permits no model tools. External executors cannot enforce this.", - type: ['array', 'null'], - items: { - type: 'string', - }, - default: null, - uniqueItems: true, + prompt: { + type: ['string', 'null'], }, - title: { - description: - 'A concise, specific task name people can recognize in navigation; avoid generic worker numbers.', + compactionPrompt: { + type: 'string', + }, + defaultCompactionPrompt: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['revision', 'prompt', 'compactionPrompt', 'defaultCompactionPrompt'], + }, + resources: {}, + }, + computerApps: { + mode: 'query', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentSessionRead', + type: 'object', + properties: { + sessionId: { type: 'string', minLength: 1, }, - labels: { + }, + additionalProperties: false, + required: ['sessionId'], + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentComputerApps', + type: 'object', + properties: { + entries: { type: 'array', items: { - type: 'string', + $ref: '#/$defs/AgentComputerAppActivityState', }, }, - workspace: { - $ref: '#/$defs/AgentWorkspaceSelection', - }, }, additionalProperties: false, - required: ['title', 'labels', 'workspace'], + required: ['entries'], $defs: { - AgentBackendPreference: { - description: - "The next turn's choice, independent of an already admitted execution.\nModel revisions are resolved afresh; preferences never grant execution authority.", - oneOf: [ - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'model', - }, - connectionId: { - type: 'string', - minLength: 1, - }, - modelId: { - type: 'string', - minLength: 1, - }, - thinkingLevel: { - anyOf: [ - { - $ref: '#/$defs/ThinkingLevel', - }, - { - type: 'null', - }, - ], - }, - }, - additionalProperties: false, - required: ['kind', 'connectionId', 'modelId'], + AgentComputerAppActivityState: { + type: 'object', + properties: { + activity: { + $ref: '#/$defs/ComputerAppActivity', }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'executor', - }, - selection: { - $ref: '#/$defs/AgentExecutorSelection', - }, - }, - additionalProperties: false, - required: ['kind', 'selection'], + stopped: { + type: 'boolean', }, - ], - }, - ThinkingLevel: { - type: 'string', - enum: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + }, + additionalProperties: false, + required: ['activity', 'stopped'], }, - AgentExecutorSelection: { + ComputerAppActivity: { type: 'object', properties: { - executor: { + invocation: { + $ref: '#/$defs/Invocation', + }, + operationId: { type: 'string', - minLength: 1, }, - settings: { - $ref: '#/$defs/ExecutorSettings', + target: { + $ref: '#/$defs/GrantTarget', + }, + display: { + $ref: '#/$defs/ComputerAppDisplay', }, }, additionalProperties: false, - required: ['executor', 'settings'], + required: ['invocation', 'operationId', 'target', 'display'], }, - ExecutorSettings: { - description: - 'Executor-owned model selection, independent of Host model connections.', + Invocation: { type: 'object', properties: { - model: { - type: ['string', 'null'], + session_id: { + type: 'string', }, - thinkingLevel: { - anyOf: [ - { - $ref: '#/$defs/ExecutorEffort', - }, - { - type: 'null', - }, - ], + turn_id: { + type: 'string', }, - runtime: { - type: ['string', 'null'], + run_id: { + type: 'string', + }, + invocation_id: { + type: 'string', }, }, - additionalProperties: false, + required: ['session_id', 'turn_id', 'run_id', 'invocation_id'], }, - ExecutorEffort: { - description: "Runtime-native effort, not a Host model's closed reasoning enum.", - type: 'string', + GrantTarget: { + type: 'object', + properties: { + providerId: { + type: 'string', + }, + contractId: { + type: 'string', + }, + serverId: { + type: 'string', + }, + toolName: { + type: 'string', + }, + capability: { + $ref: '#/$defs/GrantCapability', + }, + scope: { + $ref: '#/$defs/GrantScope', + }, + }, + required: [ + 'providerId', + 'contractId', + 'serverId', + 'toolName', + 'capability', + 'scope', + ], }, - AgentWorkspaceSelection: { + GrantCapability: { + oneOf: [ + { + type: 'string', + enum: ['browser', 'computer_use', 'computer_audio', 'desktop_mcp', 'mcp'], + }, + { + description: + 'File download consent for a browser origin, independent of page access.', + type: 'string', + const: 'browser_download', + }, + { + description: + "Read the browser profile's browsing history, independent of page access.", + type: 'string', + const: 'browser_history', + }, + { + description: + 'File upload consent for a browser origin, independent of reads and downloads.', + type: 'string', + const: 'browser_upload', + }, + ], + }, + GrantScope: { oneOf: [ { - description: 'A Git worktree of the directory at `path`, managed by the Host.', type: 'object', properties: { kind: { type: 'string', - const: 'isolated_git', + const: 'browser_origin', }, - path: { + origin: { type: 'string', - minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'path'], + required: ['kind', 'origin'], }, { - description: 'A registered Project at one of its locations.', + description: + 'One selected browser profile generation; never an origin or app-wide grant.', type: 'object', properties: { kind: { type: 'string', - const: 'project', + const: 'browser_profile', }, - project: { - $ref: '#/$defs/AgentProjectRef', + browserId: { + type: 'string', }, - path: { + generation: { type: 'string', - minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'project', 'path'], + required: ['kind', 'browserId', 'generation'], }, { - description: - 'The directory at `path`: absolute, `~` standing for the home directory,\nwithin the configured workspace locations.', type: 'object', properties: { kind: { type: 'string', - const: 'directory', + const: 'capability', }, - path: { + }, + additionalProperties: false, + required: ['kind'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'mcp_tool', + }, + serverId: { + type: 'string', + }, + toolName: { type: 'string', - minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'path'], + required: ['kind', 'serverId', 'toolName'], }, { type: 'object', properties: { kind: { type: 'string', - const: 'session', + const: 'computer_app', }, - sessionId: { + platform: { + $ref: '#/$defs/ComputerAppPlatform', + }, + applicationId: { type: 'string', - minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'sessionId'], + required: ['kind', 'platform', 'applicationId'], }, ], }, - AgentProjectRef: { + ComputerAppPlatform: { + type: 'string', + enum: ['macos', 'windows'], + }, + ComputerAppDisplay: { type: 'object', properties: { - id: { + name: { type: 'string', - format: 'uuid', }, - revision: { + appPath: { type: 'string', - pattern: '^[a-f0-9]{64}$', + }, + risk: { + anyOf: [ + { + $ref: '#/$defs/ComputerAppRisk', + }, + { + type: 'null', + }, + ], + }, + warning: { + type: ['string', 'null'], }, }, additionalProperties: false, - required: ['id', 'revision'], + required: ['name', 'appPath'], + }, + ComputerAppRisk: { + type: 'string', + enum: ['low', 'high'], }, }, }, - output: { + resources: {}, + }, + context: { + mode: 'query', + input: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentCreated', - oneOf: [ - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'created', - }, - session: { - $ref: '#/$defs/AgentSession', - }, - }, - additionalProperties: false, - required: ['kind', 'session'], + title: 'AgentContextRead', + type: 'object', + properties: { + sessionId: { + type: 'string', }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'rejected', + through: { + description: + 'Null captures the committed head; a supplied fence must not exceed it.', + type: ['integer', 'null'], + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + }, + additionalProperties: false, + required: ['sessionId', 'through'], + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentContextReport', + type: 'object', + properties: { + through: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + request: { + description: + 'Latest Main request made by this Session, excluding copied source requests.', + anyOf: [ + { + $ref: '#/$defs/AgentContextRequest', }, - reason: { - $ref: '#/$defs/AgentRejection', + { + type: 'null', }, - }, - additionalProperties: false, - required: ['kind', 'reason'], + ], }, - ], + }, + additionalProperties: false, + required: ['through', 'request'], $defs: { - AgentSession: { + AgentContextRequest: { type: 'object', properties: { - parentSessionId: { - description: - 'Session that initiated creation, independent of fork ancestry and task ownership.', - type: ['string', 'null'], - }, - branch: { - anyOf: [ - { - $ref: '#/$defs/AgentSessionBranch', - }, - { - type: 'null', - }, - ], - }, - backend: { - anyOf: [ - { - $ref: '#/$defs/AgentBackendPreference', - }, - { - type: 'null', - }, - ], + invocationId: { + type: 'string', }, - tools: { - type: ['array', 'null'], - items: { - type: 'string', - }, + stepId: { + type: 'string', }, - dataSource: { - $ref: '#/$defs/AgentDataSource', + sequence: { + type: 'integer', + format: 'uint64', + minimum: 1, + maximum: 9007199254740991, }, - permissions: { - $ref: '#/$defs/AgentWorkspacePermissions', + recordedAt: { + description: 'Original request timestamp in milliseconds since the Unix epoch.', + type: 'number', + format: 'double', + minimum: 0, + maximum: 9007199254740991, }, - boundaryRevision: { + sourceThrough: { + description: 'Original input history fence, before this request was recorded.', type: 'integer', format: 'uint64', minimum: 0, + maximum: 9007199254740991, }, - binding: { - $ref: '#/$defs/BindingStamp', - }, - id: { + modelId: { type: 'string', }, - title: { - type: 'string', + contextWindow: { + type: ['integer', 'null'], + format: 'uint64', + minimum: 1, + maximum: 9007199254740991, }, - labels: { - type: 'array', - items: { - type: 'string', - }, + inputLimit: { + type: ['integer', 'null'], + format: 'uint64', + minimum: 1, + maximum: 9007199254740991, }, - workspace: { - $ref: '#/$defs/AgentWorkspace', + state: { + $ref: '#/$defs/AgentContextRequestState', }, - revision: { - type: 'integer', - format: 'uint64', - minimum: 0, + breakdown: { + description: + 'Original Host prepared-input measurements, not provider wire sizes or exact tokenization.', + anyOf: [ + { + $ref: '#/$defs/PreparedContextBreakdown', + }, + { + type: 'null', + }, + ], }, - updatedAt: { - type: 'integer', + reportedInputTokens: { + description: + 'Provider-reported input tokens from this exact completed step, if available.', + type: ['integer', 'null'], format: 'uint64', minimum: 0, + maximum: 9007199254740991, }, - archived: { - type: 'boolean', + }, + additionalProperties: false, + required: [ + 'invocationId', + 'stepId', + 'sequence', + 'recordedAt', + 'sourceThrough', + 'modelId', + 'contextWindow', + 'inputLimit', + 'state', + 'breakdown', + 'reportedInputTokens', + ], + }, + AgentContextRequestState: { + type: 'string', + enum: ['requested', 'completed', 'interrupted'], + }, + PreparedContextBreakdown: { + description: + 'Host-prepared adapter input, not provider tokenization or HTTP wire bytes.', + type: 'object', + properties: { + system: { + $ref: '#/$defs/ContextBucket', }, - pinned: { - type: 'boolean', + user: { + $ref: '#/$defs/ContextBucket', }, - hasUnread: { - type: 'boolean', + assistant: { + $ref: '#/$defs/ContextBucket', }, - tailMessageId: { + tool: { description: - 'Exact durable visible tail accepted by markRead; independent of preview clocks.', - type: ['string', 'null'], + 'Messages with the tool role; assistant-embedded results stay in assistant.', + $ref: '#/$defs/ContextBucket', }, - activity: { - $ref: '#/$defs/AgentSessionActivity', + toolSchemas: { + $ref: '#/$defs/ContextBucket', + }, + counts: { + $ref: '#/$defs/ContextCounts', }, }, additionalProperties: false, - required: [ - 'parentSessionId', - 'branch', - 'dataSource', - 'permissions', - 'boundaryRevision', - 'binding', - 'id', - 'title', - 'labels', - 'workspace', - 'revision', - 'updatedAt', - 'archived', - 'pinned', - 'hasUnread', - 'tailMessageId', - 'activity', - ], + required: ['system', 'user', 'assistant', 'tool', 'toolSchemas', 'counts'], }, - AgentSessionBranch: { - description: - 'Frozen conversation ancestry for presentation, not authority over the source.', + ContextBucket: { type: 'object', properties: { - sessionId: { - type: 'string', - minLength: 1, + contentBytes: { + description: + 'UTF-8 text and compact JSON payload bytes counted by the Host heuristic.\nExcludes file encodings, message metadata, request-level provider options\nand synthetic framing overhead. Tool definitions are counted in full.', + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, }, - position: { - $ref: '#/$defs/AgentForkPosition', + estimatedTokens: { + description: + 'Heuristic: separately rounded text/JSON bytes / 3, plus message/tool\nframing and 1024 per media part. Never provider-measured tokens.', + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, }, }, additionalProperties: false, - required: ['sessionId', 'position'], + required: ['contentBytes', 'estimatedTokens'], }, - AgentForkPosition: { - description: 'A complete logical Turn boundary within the source session.', - oneOf: [ - { - description: 'Capture an empty history prefix.', - type: 'object', - properties: { - kind: { - type: 'string', - const: 'start', - }, - }, - required: ['kind'], - additionalProperties: false, - }, - { - description: 'Capture all current history, including later compaction facts.', - type: 'object', - properties: { - kind: { - type: 'string', - const: 'end', - }, - }, - required: ['kind'], - additionalProperties: false, + ContextCounts: { + type: 'object', + properties: { + messages: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + media: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + toolCalls: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + toolResults: { + description: 'Result parts across both tool and assistant roles.', + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + tools: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, }, + }, + additionalProperties: false, + required: ['messages', 'media', 'toolCalls', 'toolResults', 'tools'], + }, + }, + }, + resources: {}, + }, + create: { + mode: 'mutation', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentCreate', + type: 'object', + properties: { + backend: { + description: 'Initial next-turn preference; admission resolves it again.', + anyOf: [ { - description: 'Include the complete target Turn.', - type: 'object', - properties: { - kind: { - type: 'string', - const: 'through_turn', - }, - turnId: { - type: 'string', - minLength: 1, - }, - }, - additionalProperties: false, - required: ['kind', 'turnId'], + $ref: '#/$defs/AgentBackendPreference', }, { - description: - 'Exclude the complete target Turn, including its submitted inputs.', - type: 'object', - properties: { - kind: { - type: 'string', - const: 'before_turn', - }, - turnId: { - type: 'string', - minLength: 1, - }, - }, - additionalProperties: false, - required: ['kind', 'turnId'], + type: 'null', }, ], }, + automaticTitle: { + description: + 'Treat title as a placeholder for one title derived from the first user message.', + type: 'boolean', + default: false, + }, + tools: { + description: + "Immutable narrowing of this Agent's installed tools. None uses the installation;\nan empty list permits no model tools. External executors cannot enforce this.", + type: ['array', 'null'], + items: { + type: 'string', + }, + default: null, + uniqueItems: true, + }, + title: { + description: + 'A concise, specific task name people can recognize in navigation; avoid generic worker numbers.', + type: 'string', + minLength: 1, + }, + labels: { + type: 'array', + items: { + type: 'string', + }, + }, + workspace: { + $ref: '#/$defs/AgentWorkspaceSelection', + }, + }, + additionalProperties: false, + required: ['title', 'labels', 'workspace'], + $defs: { AgentBackendPreference: { description: "The next turn's choice, independent of an already admitted execution.\nModel revisions are resolved afresh; preferences never grant execution authority.", @@ -3168,316 +3342,341 @@ export const contracts = { description: "Runtime-native effort, not a Host model's closed reasoning enum.", type: 'string', }, - AgentDataSource: { - description: - 'Stable business-data origin, independent of package or activation revisions.', + AgentWorkspaceSelection: { + oneOf: [ + { + description: 'A Git worktree of the directory at `path`, managed by the Host.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'isolated_git', + }, + path: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'path'], + }, + { + description: 'A registered Project at one of its locations.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'project', + }, + project: { + $ref: '#/$defs/AgentProjectRef', + }, + path: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'project', 'path'], + }, + { + description: + 'The directory at `path`: absolute, `~` standing for the home directory,\nwithin the configured workspace locations.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'directory', + }, + path: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'path'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'session', + }, + sessionId: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'sessionId'], + }, + ], + }, + AgentProjectRef: { type: 'object', properties: { - stateDomain: { - type: 'string', - }, - deployment: { + id: { type: 'string', format: 'uuid', }, - namespace: { - $ref: '#/$defs/InstanceId', + revision: { + type: 'string', + pattern: '^[a-f0-9]{64}$', }, }, additionalProperties: false, - required: ['stateDomain', 'deployment', 'namespace'], - }, - InstanceId: { - description: - 'Hierarchical, stable source keys. Display aliases never become data identities.', - type: 'array', - items: { - type: 'string', - format: 'uuid', - }, + required: ['id', 'revision'], }, - AgentWorkspacePermissions: { + }, + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentCreated', + oneOf: [ + { type: 'object', properties: { - access: { - $ref: '#/$defs/AgentWorkspaceAccess', + kind: { + type: 'string', + const: 'created', }, - requestPermissions: { - type: 'boolean', + session: { + $ref: '#/$defs/AgentSession', }, }, additionalProperties: false, - required: ['access', 'requestPermissions'], - }, - AgentWorkspaceAccess: { - description: - 'Session sandbox policy. Full access disables isolation and permits Computer Use\nwithout additional application, browser or audio consent.', - type: 'string', - enum: ['read_only', 'workspace_write', 'danger_full_access'], + required: ['kind', 'session'], }, - BindingStamp: { + { type: 'object', properties: { - id: { + kind: { type: 'string', - format: 'uuid', + const: 'rejected', }, - permissionEpoch: { - type: 'integer', - format: 'uint64', - minimum: 0, + reason: { + $ref: '#/$defs/AgentRejection', }, }, additionalProperties: false, - required: ['id', 'permissionEpoch'], + required: ['kind', 'reason'], }, - AgentWorkspace: { + ], + $defs: { + AgentSession: { type: 'object', properties: { - worktree: { + parentSessionId: { description: - 'Native owner allocation; never accepted from a caller as workspace authority.', + 'Session that initiated creation, independent of fork ancestry and task ownership.', + type: ['string', 'null'], + }, + branch: { anyOf: [ { - $ref: '#/$defs/Binding', + $ref: '#/$defs/AgentSessionBranch', }, { type: 'null', }, ], }, - projectId: { - description: "Optional membership in this Agent's canonical Project catalog.", - type: ['string', 'null'], - format: 'uuid', + backend: { + anyOf: [ + { + $ref: '#/$defs/AgentBackendPreference', + }, + { + type: 'null', + }, + ], }, - path: { - type: 'string', - minLength: 1, + tools: { + type: ['array', 'null'], + items: { + type: 'string', + }, }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', + dataSource: { + $ref: '#/$defs/AgentDataSource', }, - identity: { - type: 'string', - pattern: '^workspace:v1:[A-Fa-f0-9-]{36}$', + permissions: { + $ref: '#/$defs/AgentWorkspacePermissions', + }, + boundaryRevision: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + binding: { + $ref: '#/$defs/BindingStamp', }, - }, - additionalProperties: false, - required: ['path', 'directoryIdentity', 'identity'], - }, - Binding: { - description: 'Immutable allocation intent, not the current child branch or HEAD.', - type: 'object', - properties: { id: { type: 'string', }, - common_dir: { + title: { type: 'string', }, - source: { - type: 'string', + labels: { + type: 'array', + items: { + type: 'string', + }, }, - source_identity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', + workspace: { + $ref: '#/$defs/AgentWorkspace', }, - base_commit: { - type: 'string', + revision: { + type: 'integer', + format: 'uint64', + minimum: 0, }, - directory: { - type: 'string', + updatedAt: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + archived: { + type: 'boolean', + }, + pinned: { + type: 'boolean', + }, + hasUnread: { + type: 'boolean', + }, + tailMessageId: { + description: + 'Exact durable visible tail accepted by markRead; independent of preview clocks.', + type: ['string', 'null'], + }, + activity: { + $ref: '#/$defs/AgentSessionActivity', }, }, additionalProperties: false, required: [ + 'parentSessionId', + 'branch', + 'dataSource', + 'permissions', + 'boundaryRevision', + 'binding', 'id', - 'common_dir', - 'source', - 'source_identity', - 'base_commit', - 'directory', - ], - }, - AgentSessionActivity: { - type: 'string', - enum: ['idle', 'working', 'waiting'], - }, - AgentRejection: { - type: 'string', - enum: [ - 'permission_change_requires_user', - 'title_invalid', - 'labels_invalid', - 'tools_unavailable', - 'busy', - 'workspace_unavailable', - 'session_unavailable', - 'history_unavailable', - 'managed_session', - 'closed', + 'title', + 'labels', + 'workspace', + 'revision', + 'updatedAt', + 'archived', + 'pinned', + 'hasUnread', + 'tailMessageId', + 'activity', ], }, - }, - }, - resources: {}, - }, - diagnostics: { - mode: 'query', - input: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentDiagnostics', - type: 'object', - properties: { - sessionId: { - type: 'string', - minLength: 1, - }, - position: { - $ref: '#/$defs/AgentDiagnosticPosition', + AgentSessionBranch: { + description: + 'Frozen conversation ancestry for presentation, not authority over the source.', + type: 'object', + properties: { + sessionId: { + type: 'string', + minLength: 1, + }, + position: { + $ref: '#/$defs/AgentForkPosition', + }, + }, + additionalProperties: false, + required: ['sessionId', 'position'], }, - }, - additionalProperties: false, - required: ['sessionId', 'position'], - $defs: { - AgentDiagnosticPosition: { + AgentForkPosition: { + description: 'A complete logical Turn boundary within the source session.', oneOf: [ { + description: 'Capture an empty history prefix.', type: 'object', properties: { kind: { type: 'string', - const: 'latest', + const: 'start', }, }, required: ['kind'], additionalProperties: false, }, { + description: 'Capture all current history, including later compaction facts.', type: 'object', properties: { kind: { type: 'string', - const: 'before', + const: 'end', }, - through: { - type: 'integer', - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, + }, + required: ['kind'], + additionalProperties: false, + }, + { + description: 'Include the complete target Turn.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'through_turn', }, - sequence: { - type: 'integer', - format: 'uint64', - minimum: 1, - maximum: 9007199254740991, + turnId: { + type: 'string', + minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'through', 'sequence'], + required: ['kind', 'turnId'], + }, + { + description: + 'Exclude the complete target Turn, including its submitted inputs.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'before_turn', + }, + turnId: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'turnId'], }, ], }, - }, - }, - output: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentDiagnosticPage', - type: 'object', - properties: { - through: { - type: 'integer', - format: 'uint64', - minimum: 0, - }, - entries: { - type: 'array', - items: { - $ref: '#/$defs/AgentDiagnostic', - }, - }, - nextBefore: { - type: ['integer', 'null'], - format: 'uint64', - minimum: 0, - }, - }, - additionalProperties: false, - required: ['through', 'entries'], - $defs: { - AgentDiagnostic: { - type: 'object', - properties: { - sequence: { - type: 'integer', - format: 'uint64', - minimum: 0, - }, - eventId: { - type: 'string', - }, - recordedAt: { - type: 'number', - format: 'double', - }, - detail: { - $ref: '#/$defs/DiagnosticDetail', - }, - }, - additionalProperties: false, - required: ['sequence', 'eventId', 'recordedAt', 'detail'], - }, - DiagnosticDetail: { + AgentBackendPreference: { + description: + "The next turn's choice, independent of an already admitted execution.\nModel revisions are resolved afresh; preferences never grant execution authority.", oneOf: [ { type: 'object', properties: { kind: { type: 'string', - const: 'execution', - }, - invocation: { - $ref: '#/$defs/Invocation', - }, - reason: { - $ref: '#/$defs/ExecutionFailureReason', - }, - modelFailure: { - anyOf: [ - { - $ref: '#/$defs/ModelFailureCause', - }, - { - type: 'null', - }, - ], + const: 'model', }, - }, - additionalProperties: false, - required: ['kind', 'invocation', 'reason'], - }, - { - type: 'object', - properties: { - kind: { + connectionId: { type: 'string', - const: 'model_step', - }, - invocation: { - $ref: '#/$defs/Invocation', + minLength: 1, }, - stepId: { + modelId: { type: 'string', + minLength: 1, }, - status: { - $ref: '#/$defs/ModelInterruption', - }, - modelFailure: { + thinkingLevel: { anyOf: [ { - $ref: '#/$defs/ModelFailureCause', + $ref: '#/$defs/ThinkingLevel', }, { type: 'null', @@ -3486,630 +3685,560 @@ export const contracts = { }, }, additionalProperties: false, - required: ['kind', 'invocation', 'stepId', 'status'], + required: ['kind', 'connectionId', 'modelId'], }, { type: 'object', properties: { kind: { type: 'string', - const: 'session_title', - }, - invocation: { - $ref: '#/$defs/Invocation', + const: 'executor', }, - cause: { - $ref: '#/$defs/TitleFailureCause', + selection: { + $ref: '#/$defs/AgentExecutorSelection', }, }, additionalProperties: false, - required: ['kind', 'invocation', 'cause'], + required: ['kind', 'selection'], }, ], }, - Invocation: { + ThinkingLevel: { + type: 'string', + enum: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + }, + AgentExecutorSelection: { type: 'object', properties: { - session_id: { - type: 'string', - }, - turn_id: { - type: 'string', - }, - run_id: { + executor: { type: 'string', + minLength: 1, }, - invocation_id: { - type: 'string', + settings: { + $ref: '#/$defs/ExecutorSettings', }, }, - required: ['session_id', 'turn_id', 'run_id', 'invocation_id'], - }, - ExecutionFailureReason: { - type: 'string', - enum: [ - 'session_busy', - 'continuation_changed', - 'reconciliation_required', - 'invalid_input', - 'cancelled', - 'model_incomplete', - 'event_commit', - 'event_store', - 'model_timeout', - 'model_http', - 'model_adapter', - 'model_provider', - 'context_overflow', - 'tool_execution', - 'runtime_internal', - 'cleanup_unconfirmed', - 'executor', - 'message_preparation_failed', - 'host_interrupted', - 'outcome_unknown', - 'execution_failed', - ], + additionalProperties: false, + required: ['executor', 'settings'], }, - ModelFailureCause: { - description: 'Safe failure evidence, separate from provider-owned diagnostic text.', - oneOf: [ - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'http', - }, - status: { - type: 'integer', - format: 'uint16', - minimum: 100, - maximum: 599, - }, - }, - additionalProperties: false, - required: ['kind', 'status'], - }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'timed_out', - }, - }, - required: ['kind'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'context_overflow', - }, - }, - required: ['kind'], - additionalProperties: false, + ExecutorSettings: { + description: + 'Executor-owned model selection, independent of Host model connections.', + type: 'object', + properties: { + model: { + type: ['string', 'null'], }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'provider', - }, - reason: { - $ref: '#/$defs/ProviderFailureReason', + thinkingLevel: { + anyOf: [ + { + $ref: '#/$defs/ExecutorEffort', }, - retry_at_unix_ms: { - type: ['integer', 'null'], - format: 'uint64', - minimum: 0, + { + type: 'null', }, - }, - additionalProperties: false, - required: ['kind', 'reason'], + ], }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'adapter', - }, - }, - required: ['kind'], - additionalProperties: false, + runtime: { + type: ['string', 'null'], }, - ], + }, + additionalProperties: false, }, - ProviderFailureReason: { + ExecutorEffort: { + description: "Runtime-native effort, not a Host model's closed reasoning enum.", type: 'string', - enum: [ - 'network', - 'timed_out', - 'rate_limit', - 'quota_exhausted', - 'provider_unavailable', - 'stream_truncated', - ], }, - ModelInterruption: { - oneOf: [ - { + AgentDataSource: { + description: + 'Stable business-data origin, independent of package or activation revisions.', + type: 'object', + properties: { + stateDomain: { type: 'string', - enum: ['steered', 'cancelled', 'timed_out', 'failed'], }, - { - description: - 'Trusted ingress permits retry: completion-only steps can discard observations;\nitem-accepting steps must retain completed items and settle their tools.\nThis records safety evidence, not an instruction to retry.', + deployment: { type: 'string', - const: 'retryable_failure', + format: 'uuid', }, - ], - }, - TitleFailureCause: { - oneOf: [ - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'preparation', - }, - }, - required: ['kind'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'model', - }, - cause: { - $ref: '#/$defs/ModelFailureCause', - }, - }, - additionalProperties: false, - required: ['kind', 'cause'], - }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'invalid_output', - }, - }, - required: ['kind'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'output_limit', - }, - }, - required: ['kind'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'empty_output', - }, - }, - required: ['kind'], - additionalProperties: false, + namespace: { + $ref: '#/$defs/InstanceId', }, - ], + }, + additionalProperties: false, + required: ['stateDomain', 'deployment', 'namespace'], }, - }, - }, - resources: {}, - }, - directories: { - mode: 'query', - input: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentDirectories', - oneOf: [ - { + InstanceId: { + description: + 'Hierarchical, stable source keys. Display aliases never become data identities.', + type: 'array', + items: { + type: 'string', + format: 'uuid', + }, + }, + AgentWorkspacePermissions: { type: 'object', properties: { - kind: { - type: 'string', - const: 'roots', + access: { + $ref: '#/$defs/AgentWorkspaceAccess', + }, + requestPermissions: { + type: 'boolean', }, }, - required: ['kind'], additionalProperties: false, + required: ['access', 'requestPermissions'], }, - { + AgentWorkspaceAccess: { description: - 'The directories inside `path`, a page at a time; `after` is the last\nname of the previous page.', + 'Session sandbox policy. Full access disables isolation and permits Computer Use\nwithout additional application, browser or audio consent.', + type: 'string', + enum: ['read_only', 'workspace_write', 'danger_full_access'], + }, + BindingStamp: { type: 'object', properties: { - kind: { - type: 'string', - const: 'children', - }, - path: { + id: { type: 'string', - minLength: 1, + format: 'uuid', }, - after: { - type: ['string', 'null'], + permissionEpoch: { + type: 'integer', + format: 'uint64', + minimum: 0, }, }, additionalProperties: false, - required: ['kind', 'path', 'after'], + required: ['id', 'permissionEpoch'], }, - ], - }, - output: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentDirectoryPage', - oneOf: [ - { + AgentWorkspace: { type: 'object', properties: { - kind: { + worktree: { + description: + 'Native owner allocation; never accepted from a caller as workspace authority.', + anyOf: [ + { + $ref: '#/$defs/Binding', + }, + { + type: 'null', + }, + ], + }, + projectId: { + description: "Optional membership in this Agent's canonical Project catalog.", + type: ['string', 'null'], + format: 'uuid', + }, + path: { type: 'string', - const: 'roots', + minLength: 1, }, - roots: { - type: 'array', - items: { - $ref: '#/$defs/AgentDirectory', - }, + directoryIdentity: { + type: 'string', + pattern: '^sha256:[a-f0-9]{64}$', + }, + identity: { + type: 'string', + pattern: '^workspace:v1:[A-Fa-f0-9-]{36}$', }, }, additionalProperties: false, - required: ['kind', 'roots'], + required: ['path', 'directoryIdentity', 'identity'], }, - { + Binding: { + description: 'Immutable allocation intent, not the current child branch or HEAD.', type: 'object', properties: { - kind: { + id: { type: 'string', - const: 'children', }, - directory: { - $ref: '#/$defs/AgentDirectory', + common_dir: { + type: 'string', }, - children: { - type: 'array', - items: { - $ref: '#/$defs/AgentDirectory', - }, - maxItems: 32, + source: { + type: 'string', }, - after: { - type: ['string', 'null'], + source_identity: { + type: 'string', + pattern: '^sha256:[a-f0-9]{64}$', }, - }, - additionalProperties: false, - required: ['kind', 'directory', 'children', 'after'], - }, - ], - $defs: { - AgentDirectory: { - type: 'object', - properties: { - name: { + base_commit: { type: 'string', }, - path: { + directory: { type: 'string', }, }, additionalProperties: false, - required: ['name', 'path'], + required: [ + 'id', + 'common_dir', + 'source', + 'source_identity', + 'base_commit', + 'directory', + ], + }, + AgentSessionActivity: { + type: 'string', + enum: ['idle', 'working', 'waiting'], + }, + AgentRejection: { + type: 'string', + enum: [ + 'permission_change_requires_user', + 'title_invalid', + 'labels_invalid', + 'tools_unavailable', + 'busy', + 'workspace_unavailable', + 'session_unavailable', + 'history_unavailable', + 'managed_session', + 'closed', + ], }, }, }, resources: {}, }, - editQueue: { - mode: 'mutation', + diagnostics: { + mode: 'query', input: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentQueueMutation', + title: 'AgentDiagnostics', type: 'object', properties: { sessionId: { type: 'string', minLength: 1, }, - expectedRevision: { - type: 'integer', - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, - }, - edit: { - $ref: '#/$defs/AgentQueueAction', + position: { + $ref: '#/$defs/AgentDiagnosticPosition', }, }, additionalProperties: false, - required: ['sessionId', 'expectedRevision', 'edit'], + required: ['sessionId', 'position'], $defs: { - AgentQueueAction: { + AgentDiagnosticPosition: { oneOf: [ { type: 'object', properties: { kind: { type: 'string', - const: 'retract', - }, - messageId: { - type: 'string', - minLength: 1, + const: 'latest', }, }, + required: ['kind'], additionalProperties: false, - required: ['kind', 'messageId'], }, { type: 'object', properties: { kind: { type: 'string', - const: 'promote', + const: 'before', }, - messageId: { - type: 'string', - minLength: 1, + through: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + sequence: { + type: 'integer', + format: 'uint64', + minimum: 1, + maximum: 9007199254740991, }, }, additionalProperties: false, - required: ['kind', 'messageId'], + required: ['kind', 'through', 'sequence'], + }, + ], + }, + }, + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentDiagnosticPage', + type: 'object', + properties: { + through: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + entries: { + type: 'array', + items: { + $ref: '#/$defs/AgentDiagnostic', + }, + }, + nextBefore: { + type: ['integer', 'null'], + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['through', 'entries'], + $defs: { + AgentDiagnostic: { + type: 'object', + properties: { + sequence: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + eventId: { + type: 'string', + }, + recordedAt: { + type: 'number', + format: 'double', + }, + detail: { + $ref: '#/$defs/DiagnosticDetail', }, + }, + additionalProperties: false, + required: ['sequence', 'eventId', 'recordedAt', 'detail'], + }, + DiagnosticDetail: { + oneOf: [ { type: 'object', properties: { kind: { type: 'string', - const: 'update', + const: 'execution', }, - messageId: { + invocation: { + $ref: '#/$defs/Invocation', + }, + reason: { + $ref: '#/$defs/ExecutionFailureReason', + }, + modelFailure: { + anyOf: [ + { + $ref: '#/$defs/ModelFailureCause', + }, + { + type: 'null', + }, + ], + }, + }, + additionalProperties: false, + required: ['kind', 'invocation', 'reason'], + }, + { + type: 'object', + properties: { + kind: { type: 'string', - minLength: 1, + const: 'model_step', }, - message: { + invocation: { + $ref: '#/$defs/Invocation', + }, + stepId: { type: 'string', }, + status: { + $ref: '#/$defs/ModelInterruption', + }, + modelFailure: { + anyOf: [ + { + $ref: '#/$defs/ModelFailureCause', + }, + { + type: 'null', + }, + ], + }, }, additionalProperties: false, - required: ['kind', 'messageId', 'message'], + required: ['kind', 'invocation', 'stepId', 'status'], }, { type: 'object', properties: { kind: { type: 'string', - const: 'reorder', + const: 'session_title', }, - messageIds: { - type: 'array', - items: { - type: 'string', - }, - minItems: 1, + invocation: { + $ref: '#/$defs/Invocation', + }, + cause: { + $ref: '#/$defs/TitleFailureCause', }, }, additionalProperties: false, - required: ['kind', 'messageIds'], + required: ['kind', 'invocation', 'cause'], }, ], }, - }, - }, - output: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentQueueMutationResult', - oneOf: [ - { + Invocation: { type: 'object', properties: { - kind: { + session_id: { type: 'string', - const: 'committed', - }, - revision: { - type: 'integer', - format: 'uint64', - minimum: 0, }, - }, - additionalProperties: false, - required: ['kind', 'revision'], - }, - { - type: 'object', - properties: { - kind: { + turn_id: { type: 'string', - const: 'revision_conflict', - }, - expected: { - type: 'integer', - format: 'uint64', - minimum: 0, - }, - actual: { - type: 'integer', - format: 'uint64', - minimum: 0, }, - }, - additionalProperties: false, - required: ['kind', 'expected', 'actual'], - }, - { - type: 'object', - properties: { - kind: { + run_id: { type: 'string', - const: 'rejected', }, - reason: { - $ref: '#/$defs/AgentQueueRejection', + invocation_id: { + type: 'string', }, }, - additionalProperties: false, - required: ['kind', 'reason'], + required: ['session_id', 'turn_id', 'run_id', 'invocation_id'], }, - ], - $defs: { - AgentQueueRejection: { + ExecutionFailureReason: { type: 'string', enum: [ - 'not_your_message', - 'closed', - 'missing', - 'in_flight', - 'invalid_edit', - 'execution_changed', - 'not_running', + 'session_busy', + 'continuation_changed', + 'reconciliation_required', + 'invalid_input', + 'cancelled', + 'model_incomplete', + 'event_commit', + 'event_store', + 'model_timeout', + 'model_http', + 'model_adapter', + 'model_provider', + 'context_overflow', + 'tool_execution', + 'runtime_internal', + 'cleanup_unconfirmed', + 'executor', + 'message_preparation_failed', + 'host_interrupted', + 'outcome_unknown', + 'execution_failed', ], }, - }, - }, - resources: {}, - }, - execution: { - mode: 'query', - input: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentExecutionRead', - type: 'object', - properties: { - sessionId: { - type: 'string', - minLength: 1, - }, - invocationId: { - type: 'string', - minLength: 1, - }, - }, - additionalProperties: false, - required: ['sessionId', 'invocationId'], - }, - output: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentExecutionResult', - oneOf: [ - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'missing', - }, - }, - required: ['kind'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'found', - }, - execution: { - $ref: '#/$defs/AgentExecution', - }, - }, - additionalProperties: false, - required: ['kind', 'execution'], - }, - ], - $defs: { - AgentExecution: { - description: - 'Canonical physical execution receipt, independent of the current Session tip.', - type: 'object', - properties: { - sessionId: { - type: 'string', - }, - turnId: { - type: 'string', + ModelFailureCause: { + description: 'Safe failure evidence, separate from provider-owned diagnostic text.', + oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'http', + }, + status: { + type: 'integer', + format: 'uint16', + minimum: 100, + maximum: 599, + }, + }, + additionalProperties: false, + required: ['kind', 'status'], }, - runId: { - type: 'string', + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'timed_out', + }, + }, + required: ['kind'], + additionalProperties: false, }, - invocationId: { - type: 'string', + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'context_overflow', + }, + }, + required: ['kind'], + additionalProperties: false, }, - highWater: { - description: 'Run-local event count, not the root-global transcript cursor.', - type: 'integer', - format: 'uint64', - minimum: 0, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'provider', + }, + reason: { + $ref: '#/$defs/ProviderFailureReason', + }, + retry_at_unix_ms: { + type: ['integer', 'null'], + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'reason'], }, - state: { - $ref: '#/$defs/AgentExecutionState', + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'adapter', + }, + }, + required: ['kind'], + additionalProperties: false, }, - }, - additionalProperties: false, - required: ['sessionId', 'turnId', 'runId', 'invocationId', 'highWater', 'state'], + ], }, - AgentExecutionState: { + ProviderFailureReason: { type: 'string', enum: [ - 'admitted', - 'running', - 'waiting', - 'paused', - 'completed', - 'failed', - 'cancelled', + 'network', + 'timed_out', + 'rate_limit', + 'quota_exhausted', + 'provider_unavailable', + 'stream_truncated', ], }, - }, - }, - resources: {}, - }, - executorSetup: { - mode: 'mutation', - input: { - type: 'object', - additionalProperties: false, - properties: { - executor: { - type: 'string', - minLength: 1, - }, - action: { - $ref: '#/$defs/ExecutorSetupAction', + ModelInterruption: { + oneOf: [ + { + type: 'string', + enum: ['steered', 'cancelled', 'timed_out', 'failed'], + }, + { + description: + 'Trusted ingress permits retry: completion-only steps can discard observations;\nitem-accepting steps must retain completed items and settle their tools.\nThis records safety evidence, not an instruction to retry.', + type: 'string', + const: 'retryable_failure', + }, + ], }, - }, - required: ['executor', 'action'], - title: 'AgentExecutorSetup', - $schema: 'https://json-schema.org/draft/2020-12/schema', - $defs: { - ExecutorSetupAction: { + TitleFailureCause: { oneOf: [ { type: 'object', properties: { kind: { type: 'string', - const: 'check', + const: 'preparation', }, }, required: ['kind'], @@ -4117,385 +4246,451 @@ export const contracts = { }, { type: 'object', + properties: { + kind: { + type: 'string', + const: 'model', + }, + cause: { + $ref: '#/$defs/ModelFailureCause', + }, + }, additionalProperties: false, + required: ['kind', 'cause'], + }, + { + type: 'object', properties: { kind: { type: 'string', - const: 'authenticate', + const: 'invalid_output', }, - methodId: { + }, + required: ['kind'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { type: 'string', - minLength: 1, + const: 'output_limit', }, }, - required: ['kind', 'methodId'], + required: ['kind'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'empty_output', + }, + }, + required: ['kind'], + additionalProperties: false, }, ], }, }, }, - output: { + resources: {}, + }, + directories: { + mode: 'query', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentDirectories', oneOf: [ { type: 'object', - additionalProperties: false, properties: { kind: { type: 'string', - const: 'checked', - }, - info: { - anyOf: [ - { - $ref: '#/$defs/ExecutorSetupInfo', - }, - { - type: 'null', - }, - ], - }, - methods: { - type: 'array', - items: { - $ref: '#/$defs/ExecutorAuthenticationMethod', - }, + const: 'roots', }, }, - required: ['kind', 'methods'], + required: ['kind'], + additionalProperties: false, }, { + description: + 'The directories inside `path`, a page at a time; `after` is the last\nname of the previous page.', type: 'object', properties: { kind: { type: 'string', - const: 'authenticated', + const: 'children', + }, + path: { + type: 'string', + minLength: 1, + }, + after: { + type: ['string', 'null'], }, }, - required: ['kind'], additionalProperties: false, + required: ['kind', 'path', 'after'], }, + ], + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentDirectoryPage', + oneOf: [ { type: 'object', - additionalProperties: false, properties: { kind: { type: 'string', - const: 'unresolved', - }, - diagnostic: { - type: 'string', + const: 'roots', }, - }, - required: ['kind', 'diagnostic'], - description: - 'No authentication confirmation was observed; never automatically replay it.', - }, - ], - title: 'ExecutorSetupResult', - $schema: 'https://json-schema.org/draft/2020-12/schema', - $defs: { - ExecutorSetupInfo: { - type: 'object', - additionalProperties: false, - properties: { - title: { - type: ['string', 'null'], - }, - name: { - type: 'string', - }, - version: { - type: 'string', + roots: { + type: 'array', + items: { + $ref: '#/$defs/AgentDirectory', + }, }, }, - required: ['name', 'version'], + additionalProperties: false, + required: ['kind', 'roots'], }, - ExecutorAuthenticationMethod: { + { type: 'object', - additionalProperties: false, properties: { - id: { + kind: { type: 'string', - minLength: 1, + const: 'children', }, - name: { - type: 'string', - minLength: 1, + directory: { + $ref: '#/$defs/AgentDirectory', }, - description: { - type: ['string', 'null'], + children: { + type: 'array', + items: { + $ref: '#/$defs/AgentDirectory', + }, + maxItems: 32, }, - supported: { - type: 'boolean', + after: { + type: ['string', 'null'], }, }, - required: ['id', 'name', 'supported'], + additionalProperties: false, + required: ['kind', 'directory', 'children', 'after'], }, - }, - }, - progress: { - oneOf: [ - { + ], + $defs: { + AgentDirectory: { type: 'object', - additionalProperties: false, properties: { - kind: { + name: { type: 'string', - const: 'authorization_url', }, - url: { + path: { type: 'string', }, }, - required: ['kind', 'url'], - }, - ], - title: 'ExecutorSetupProgress', - $schema: 'https://json-schema.org/draft/2020-12/schema', - }, - resources: {}, - }, - executors: { - mode: 'query', - input: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentExecutorsRead', - type: 'object', - additionalProperties: false, - }, - output: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentExecutors', - type: 'object', - properties: { - executors: { - type: 'array', - items: { - type: 'string', - }, - }, - setup: { - type: 'array', - items: { - type: 'string', - }, + additionalProperties: false, + required: ['name', 'path'], }, }, - additionalProperties: false, - required: ['executors', 'setup'], }, resources: {}, }, - externalAgents: { - mode: 'query', + editQueue: { + mode: 'mutation', input: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentExternalAgentsRead', - type: 'object', - properties: { - inspect: { - type: 'boolean', - default: false, - }, - }, - additionalProperties: false, - }, - output: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentExternalAgents', + title: 'AgentQueueMutation', type: 'object', properties: { - target: { - $ref: '#/$defs/AgentExternalAgentsTarget', - }, - configured: { - type: 'boolean', + sessionId: { + type: 'string', + minLength: 1, }, - runtimes: { - type: 'array', - items: { - $ref: '#/$defs/AgentExternalRuntimeOption', - }, + expectedRevision: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, }, - state: { - $ref: '#/$defs/AgentExternalAgentsState', + edit: { + $ref: '#/$defs/AgentQueueAction', }, }, additionalProperties: false, - required: ['target', 'configured', 'runtimes', 'state'], + required: ['sessionId', 'expectedRevision', 'edit'], $defs: { - AgentExternalAgentsTarget: { - type: 'object', - properties: { - source: { - $ref: '#/$defs/AgentDataSource', - }, - path: { - type: 'array', - items: { - type: 'string', - }, - }, - }, - additionalProperties: false, - required: ['source', 'path'], - }, - AgentDataSource: { - description: - 'Stable business-data origin, independent of package or activation revisions.', - type: 'object', - properties: { - stateDomain: { - type: 'string', - }, - deployment: { - type: 'string', - format: 'uuid', - }, - namespace: { - $ref: '#/$defs/InstanceId', - }, - }, - additionalProperties: false, - required: ['stateDomain', 'deployment', 'namespace'], - }, - InstanceId: { - description: - 'Hierarchical, stable source keys. Display aliases never become data identities.', - type: 'array', - items: { - type: 'string', - format: 'uuid', - }, - }, - AgentExternalRuntimeOption: { - type: 'object', - properties: { - id: { - type: 'string', - }, - name: { - type: 'string', - }, - enabled: { - type: 'boolean', - }, - }, - additionalProperties: false, - required: ['id', 'name', 'enabled'], - }, - AgentExternalAgentsState: { + AgentQueueAction: { oneOf: [ { type: 'object', properties: { kind: { type: 'string', - const: 'disabled', + const: 'retract', }, - }, - required: ['kind'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - kind: { + messageId: { type: 'string', - const: 'enabled', + minLength: 1, }, }, - required: ['kind'], additionalProperties: false, + required: ['kind', 'messageId'], }, { type: 'object', properties: { kind: { type: 'string', - const: 'workspace_required', + const: 'promote', + }, + messageId: { + type: 'string', + minLength: 1, }, }, - required: ['kind'], additionalProperties: false, + required: ['kind', 'messageId'], }, { type: 'object', properties: { kind: { type: 'string', - const: 'unavailable', + const: 'update', + }, + messageId: { + type: 'string', + minLength: 1, }, message: { type: 'string', }, }, additionalProperties: false, - required: ['kind', 'message'], + required: ['kind', 'messageId', 'message'], }, { type: 'object', properties: { kind: { type: 'string', - const: 'ready', + const: 'reorder', }, - runtimes: { + messageIds: { type: 'array', items: { - $ref: '#/$defs/AgentExternalRuntime', + type: 'string', }, + minItems: 1, }, }, additionalProperties: false, - required: ['kind', 'runtimes'], + required: ['kind', 'messageIds'], }, ], }, - AgentExternalRuntime: { + }, + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentQueueMutationResult', + oneOf: [ + { type: 'object', properties: { - id: { + kind: { type: 'string', + const: 'committed', }, - name: { + revision: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'revision'], + }, + { + type: 'object', + properties: { + kind: { type: 'string', + const: 'revision_conflict', }, - installation: { - $ref: '#/$defs/AgentExternalInstallation', + expected: { + type: 'integer', + format: 'uint64', + minimum: 0, }, - authentication: { - $ref: '#/$defs/AgentExternalAuthentication', + actual: { + type: 'integer', + format: 'uint64', + minimum: 0, }, - models: { - $ref: '#/$defs/AgentExternalModels', + }, + additionalProperties: false, + required: ['kind', 'expected', 'actual'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'rejected', + }, + reason: { + $ref: '#/$defs/AgentQueueRejection', }, }, additionalProperties: false, - required: ['id', 'name', 'installation', 'authentication', 'models'], + required: ['kind', 'reason'], }, - AgentExternalInstallation: { - oneOf: [ - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'available', - }, - version: { - type: ['string', 'null'], - }, - }, - additionalProperties: false, - required: ['kind'], + ], + $defs: { + AgentQueueRejection: { + type: 'string', + enum: [ + 'not_your_message', + 'closed', + 'missing', + 'in_flight', + 'invalid_edit', + 'execution_changed', + 'not_running', + ], + }, + }, + }, + resources: {}, + }, + execution: { + mode: 'query', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentExecutionRead', + type: 'object', + properties: { + sessionId: { + type: 'string', + minLength: 1, + }, + invocationId: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['sessionId', 'invocationId'], + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentExecutionResult', + oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'missing', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'found', + }, + execution: { + $ref: '#/$defs/AgentExecution', + }, + }, + additionalProperties: false, + required: ['kind', 'execution'], + }, + ], + $defs: { + AgentExecution: { + description: + 'Canonical physical execution receipt, independent of the current Session tip.', + type: 'object', + properties: { + sessionId: { + type: 'string', + }, + turnId: { + type: 'string', + }, + runId: { + type: 'string', + }, + invocationId: { + type: 'string', + }, + highWater: { + description: 'Run-local event count, not the root-global transcript cursor.', + type: 'integer', + format: 'uint64', + minimum: 0, }, + state: { + $ref: '#/$defs/AgentExecutionState', + }, + }, + additionalProperties: false, + required: ['sessionId', 'turnId', 'runId', 'invocationId', 'highWater', 'state'], + }, + AgentExecutionState: { + type: 'string', + enum: [ + 'admitted', + 'running', + 'waiting', + 'paused', + 'completed', + 'failed', + 'cancelled', + ], + }, + }, + }, + resources: {}, + }, + executorSetup: { + mode: 'mutation', + input: { + type: 'object', + additionalProperties: false, + properties: { + executor: { + type: 'string', + minLength: 1, + }, + action: { + $ref: '#/$defs/ExecutorSetupAction', + }, + }, + required: ['executor', 'action'], + title: 'AgentExecutorSetup', + $schema: 'https://json-schema.org/draft/2020-12/schema', + $defs: { + ExecutorSetupAction: { + oneOf: [ { type: 'object', properties: { kind: { type: 'string', - const: 'not_found', + const: 'check', }, }, required: ['kind'], @@ -4503,73 +4698,292 @@ export const contracts = { }, { type: 'object', - properties: { - kind: { - type: 'string', - const: 'unsupported', - }, - detail: { - type: 'string', - }, - }, additionalProperties: false, - required: ['kind', 'detail'], - }, - { - type: 'object', properties: { kind: { type: 'string', - const: 'error', + const: 'authenticate', }, - detail: { + methodId: { type: 'string', + minLength: 1, }, }, - additionalProperties: false, - required: ['kind', 'detail'], + required: ['kind', 'methodId'], }, ], }, - AgentExternalAuthentication: { - oneOf: [ - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'authenticated', + }, + }, + output: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + kind: { + type: 'string', + const: 'checked', + }, + info: { + anyOf: [ + { + $ref: '#/$defs/ExecutorSetupInfo', }, - source: { - type: 'string', + { + type: 'null', }, + ], + }, + methods: { + type: 'array', + items: { + $ref: '#/$defs/ExecutorAuthenticationMethod', }, - additionalProperties: false, - required: ['kind', 'source'], }, - { - type: 'object', + }, + required: ['kind', 'methods'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'authenticated', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { + type: 'string', + const: 'unresolved', + }, + diagnostic: { + type: 'string', + }, + }, + required: ['kind', 'diagnostic'], + description: + 'No authentication confirmation was observed; never automatically replay it.', + }, + ], + title: 'ExecutorSetupResult', + $schema: 'https://json-schema.org/draft/2020-12/schema', + $defs: { + ExecutorSetupInfo: { + type: 'object', + additionalProperties: false, + properties: { + title: { + type: ['string', 'null'], + }, + name: { + type: 'string', + }, + version: { + type: 'string', + }, + }, + required: ['name', 'version'], + }, + ExecutorAuthenticationMethod: { + type: 'object', + additionalProperties: false, + properties: { + id: { + type: 'string', + minLength: 1, + }, + name: { + type: 'string', + minLength: 1, + }, + description: { + type: ['string', 'null'], + }, + supported: { + type: 'boolean', + }, + }, + required: ['id', 'name', 'supported'], + }, + }, + }, + progress: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + kind: { + type: 'string', + const: 'authorization_url', + }, + url: { + type: 'string', + }, + }, + required: ['kind', 'url'], + }, + ], + title: 'ExecutorSetupProgress', + $schema: 'https://json-schema.org/draft/2020-12/schema', + }, + resources: {}, + }, + executors: { + mode: 'query', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentExecutorsRead', + type: 'object', + additionalProperties: false, + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentExecutors', + type: 'object', + properties: { + executors: { + type: 'array', + items: { + type: 'string', + }, + }, + setup: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + additionalProperties: false, + required: ['executors', 'setup'], + }, + resources: {}, + }, + externalAgents: { + mode: 'query', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentExternalAgentsRead', + type: 'object', + properties: { + inspect: { + type: 'boolean', + default: false, + }, + }, + additionalProperties: false, + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentExternalAgents', + type: 'object', + properties: { + target: { + $ref: '#/$defs/AgentExternalAgentsTarget', + }, + configured: { + type: 'boolean', + }, + runtimes: { + type: 'array', + items: { + $ref: '#/$defs/AgentExternalRuntimeOption', + }, + }, + state: { + $ref: '#/$defs/AgentExternalAgentsState', + }, + }, + additionalProperties: false, + required: ['target', 'configured', 'runtimes', 'state'], + $defs: { + AgentExternalAgentsTarget: { + type: 'object', + properties: { + source: { + $ref: '#/$defs/AgentDataSource', + }, + path: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + additionalProperties: false, + required: ['source', 'path'], + }, + AgentDataSource: { + description: + 'Stable business-data origin, independent of package or activation revisions.', + type: 'object', + properties: { + stateDomain: { + type: 'string', + }, + deployment: { + type: 'string', + format: 'uuid', + }, + namespace: { + $ref: '#/$defs/InstanceId', + }, + }, + additionalProperties: false, + required: ['stateDomain', 'deployment', 'namespace'], + }, + InstanceId: { + description: + 'Hierarchical, stable source keys. Display aliases never become data identities.', + type: 'array', + items: { + type: 'string', + format: 'uuid', + }, + }, + AgentExternalRuntimeOption: { + type: 'object', + properties: { + id: { + type: 'string', + }, + name: { + type: 'string', + }, + enabled: { + type: 'boolean', + }, + }, + additionalProperties: false, + required: ['id', 'name', 'enabled'], + }, + AgentExternalAgentsState: { + oneOf: [ + { + type: 'object', properties: { kind: { type: 'string', - const: 'unauthenticated', - }, - source: { - type: 'string', - }, - detail: { - type: ['string', 'null'], + const: 'disabled', }, }, + required: ['kind'], additionalProperties: false, - required: ['kind', 'source'], }, { type: 'object', properties: { kind: { type: 'string', - const: 'unsupported', + const: 'enabled', }, }, required: ['kind'], @@ -4580,22 +4994,189 @@ export const contracts = { properties: { kind: { type: 'string', - const: 'unknown', - }, - detail: { - type: ['string', 'null'], - }, - source: { - type: ['string', 'null'], - }, - configured: { - type: ['array', 'null'], - items: { - type: 'string', - }, + const: 'workspace_required', }, }, - additionalProperties: false, + required: ['kind'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'unavailable', + }, + message: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['kind', 'message'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'ready', + }, + runtimes: { + type: 'array', + items: { + $ref: '#/$defs/AgentExternalRuntime', + }, + }, + }, + additionalProperties: false, + required: ['kind', 'runtimes'], + }, + ], + }, + AgentExternalRuntime: { + type: 'object', + properties: { + id: { + type: 'string', + }, + name: { + type: 'string', + }, + installation: { + $ref: '#/$defs/AgentExternalInstallation', + }, + authentication: { + $ref: '#/$defs/AgentExternalAuthentication', + }, + models: { + $ref: '#/$defs/AgentExternalModels', + }, + }, + additionalProperties: false, + required: ['id', 'name', 'installation', 'authentication', 'models'], + }, + AgentExternalInstallation: { + oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'available', + }, + version: { + type: ['string', 'null'], + }, + }, + additionalProperties: false, + required: ['kind'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'not_found', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'unsupported', + }, + detail: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['kind', 'detail'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'error', + }, + detail: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['kind', 'detail'], + }, + ], + }, + AgentExternalAuthentication: { + oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'authenticated', + }, + source: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['kind', 'source'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'unauthenticated', + }, + source: { + type: 'string', + }, + detail: { + type: ['string', 'null'], + }, + }, + additionalProperties: false, + required: ['kind', 'source'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'unsupported', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'unknown', + }, + detail: { + type: ['string', 'null'], + }, + source: { + type: ['string', 'null'], + }, + configured: { + type: ['array', 'null'], + items: { + type: 'string', + }, + }, + }, + additionalProperties: false, required: ['kind'], }, { @@ -7562,20 +8143,18 @@ export const contracts = { }, resources: {}, }, - historyCheckpointReceipt: { + historyCheckpointLoadReceipt: { mode: 'query', input: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentHistoryCheckpointReceiptRead', + title: 'AgentCheckpointLoadReceiptRead', type: 'object', properties: { reference: { $ref: '#/$defs/RecoveryRef', }, input: { - description: - 'The exact original input. Changed input cannot select another publication.', - $ref: '#/$defs/AgentHistoryCheckpointPublish', + $ref: '#/$defs/AgentHistoryCheckpointRead', }, }, additionalProperties: false, @@ -7681,37 +8260,31 @@ export const contracts = { type: 'string', enum: ['journal_v1'], }, - AgentHistoryCheckpointPublish: { + AgentHistoryCheckpointRead: { type: 'object', properties: { sessionId: { type: 'string', minLength: 1, }, - expectedRevision: { - type: ['string', 'null'], - format: 'uuid', - }, - confirmedSubtreeDigest: { + revision: { description: - 'Membership confirmation returned by previewHistoryCheckpoint, not a live revision.', + 'Only the exact currently retained revision is readable; no latest fallback.', type: 'string', - pattern: '^[a-f0-9]{64}$', + format: 'uuid', }, }, additionalProperties: false, - required: ['sessionId', 'expectedRevision', 'confirmedSubtreeDigest'], + required: ['sessionId', 'revision'], }, }, }, output: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentHistoryCheckpointReceipt', + title: 'AgentCheckpointLoadReceipt', type: 'object', properties: { operation: { - description: - 'Original Host observation. Domain success never fabricates Host cleanup confirmation.', anyOf: [ { $ref: '#/$defs/OperationObservation', @@ -7721,12 +8294,10 @@ export const contracts = { }, ], }, - publication: { - description: - 'Authoritative domain result even when the Host response was lost.\nAbsence is not permission to recapture under a replacement operation.', + load: { anyOf: [ { - $ref: '#/$defs/AgentHistoryCheckpointPublication', + $ref: '#/$defs/AgentCheckpointLoad', }, { type: 'null', @@ -7935,28 +8506,76 @@ export const contracts = { }, ], }, - AgentHistoryCheckpointPublication: { + AgentCheckpointLoad: { type: 'object', properties: { + head: { + $ref: '#/$defs/AgentHistoryCheckpointHead', + }, + inspection: { + $ref: '#/$defs/AgentCheckpointInspection', + }, state: { - $ref: '#/$defs/AgentHistoryCheckpointState', + $ref: '#/$defs/AgentCheckpointLoadState', }, - cleanupPending: { - description: 'Cleanup failure does not undo a successful publication.', - type: 'boolean', + }, + additionalProperties: false, + required: ['head', 'inspection', 'state'], + }, + AgentHistoryCheckpointHead: { + type: 'object', + properties: { + revision: { + type: 'string', + format: 'uuid', + }, + manifest: { + $ref: '#/$defs/AgentHistoryCheckpointObject', }, }, additionalProperties: false, - required: ['state', 'cleanupPending'], + required: ['revision', 'manifest'], }, - AgentHistoryCheckpointState: { + AgentHistoryCheckpointObject: { + type: 'object', + properties: { + digest: { + type: 'string', + pattern: '^[a-f0-9]{64}$', + }, + bytes: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + }, + additionalProperties: false, + required: ['digest', 'bytes'], + }, + AgentCheckpointInspection: { + type: 'object', + properties: { + sourceSessionId: { + type: 'string', + minLength: 1, + }, + operationId: { + type: 'string', + format: 'uuid', + }, + }, + additionalProperties: false, + required: ['sourceSessionId', 'operationId'], + }, + AgentCheckpointLoadState: { oneOf: [ { type: 'object', properties: { kind: { type: 'string', - const: 'capturing', + const: 'preparing', }, }, required: ['kind'], @@ -7967,51 +8586,27 @@ export const contracts = { properties: { kind: { type: 'string', - const: 'prepared', + const: 'ready', }, - }, - required: ['kind'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'published', - }, - head: { - $ref: '#/$defs/AgentHistoryCheckpointHead', - }, - }, - additionalProperties: false, - required: ['kind', 'head'], - }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'conflict', - }, - actual: { - type: ['string', 'null'], - format: 'uuid', + sessionIds: { + type: 'array', + items: { + type: 'string', + }, }, - }, - additionalProperties: false, - required: ['kind'], - }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'interrupted', + workspace: { + anyOf: [ + { + $ref: '#/$defs/AgentCheckpointWorkspaceSnapshot', + }, + { + type: 'null', + }, + ], }, }, - required: ['kind'], additionalProperties: false, + required: ['kind', 'sessionIds'], }, { type: 'object', @@ -8026,36 +8621,50 @@ export const contracts = { }, ], }, - AgentHistoryCheckpointHead: { + AgentCheckpointWorkspaceSnapshot: { type: 'object', properties: { - revision: { + identity: { type: 'string', - format: 'uuid', }, - manifest: { + consistency: { + $ref: '#/$defs/AgentCheckpointWorkspaceConsistency', + }, + warning: { + $ref: '#/$defs/AgentCheckpointWorkspaceWarning', + }, + inventory: { $ref: '#/$defs/AgentHistoryCheckpointObject', }, }, additionalProperties: false, - required: ['revision', 'manifest'], + required: ['identity', 'consistency', 'warning', 'inventory'], }, - AgentHistoryCheckpointObject: { - type: 'object', - properties: { - digest: { + AgentCheckpointWorkspaceConsistency: { + oneOf: [ + { + description: + 'The caller confirms all other writers remain paused for the entire copy.', type: 'string', - pattern: '^[a-f0-9]{64}$', + const: 'caller_confirmed_quiescent', }, - bytes: { - type: 'integer', - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, + { + description: + 'Files may not represent one instant; successful digest checks do not change this.', + type: 'string', + const: 'unverified', }, - }, - additionalProperties: false, - required: ['digest', 'bytes'], + ], + }, + AgentCheckpointWorkspaceWarning: { + oneOf: [ + { + description: + 'Display to users: pause editors, terminals and other writers for the entire\ncapture. Hash verification is not a point-in-time consistency guarantee.', + type: 'string', + const: 'external_writers_not_fenced', + }, + ], }, }, }, @@ -11305,180 +11914,1318 @@ export const contracts = { properties: { kind: { type: 'string', - const: 'computer_app_decision', - }, - decision: { - $ref: '#/$defs/ComputerAppDecision', - }, - committedAt: { - type: 'integer', - format: 'uint64', - minimum: 0, + const: 'computer_app_decision', + }, + decision: { + $ref: '#/$defs/ComputerAppDecision', + }, + committedAt: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'decision', 'committedAt'], + }, + { + description: + 'Producer-committed once every requested system grant is present; no answer maps here.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'system_permission_granted', + }, + committedAt: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'committedAt'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'closure', + }, + reason: { + $ref: '#/$defs/ClosureReason', + }, + committedAt: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'reason', 'committedAt'], + }, + ], + }, + Decision: { + type: 'string', + enum: ['allow', 'deny'], + }, + Decision2: { + oneOf: [ + { + type: 'object', + properties: { + decision: { + type: 'string', + const: 'deny', + }, + }, + required: ['decision'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + decision: { + type: 'string', + const: 'allow', + }, + permissions: { + $ref: '#/$defs/Permissions', + }, + scope: { + $ref: '#/$defs/Scope2', + }, + }, + additionalProperties: false, + required: ['decision', 'permissions', 'scope'], + }, + ], + }, + FormValue: { + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + format: 'double', + }, + { + type: 'boolean', + }, + { + type: 'array', + items: { + type: 'string', + }, + }, + ], + }, + ComputerAppDecision: { + type: 'string', + enum: ['once', 'session', 'always', 'deny'], + }, + ClosureReason: { + type: 'string', + enum: [ + 'steered', + 'turn_stopped', + 'turn_terminal', + 'producer_cancelled', + 'timed_out', + 'host_restarted', + 'provider_disconnected', + ], + }, + }, + }, + resources: {}, + }, + list: { + mode: 'query', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentList', + type: 'object', + properties: { + filter: { + $ref: '#/$defs/AgentSessionFilter', + }, + includeArchived: { + description: 'Include archived conversations alongside active ones.', + type: 'boolean', + default: false, + }, + rootsOnly: { + description: + 'Only conversations without a parent; omitted queries still include children.', + type: 'boolean', + default: false, + }, + position: { + $ref: '#/$defs/AgentSessionPosition', + }, + scope: { + $ref: '#/$defs/AgentSessionListScope', + default: { + kind: 'all', + }, + }, + titleQuery: { + description: + 'Literal, case-sensitive title substring; an empty query matches all titles.', + type: 'string', + default: '', + }, + }, + additionalProperties: false, + required: ['filter', 'position'], + $defs: { + AgentSessionFilter: { + type: 'string', + enum: ['all', 'archived', 'needs_you'], + }, + AgentSessionPosition: { + oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'start', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'at', + }, + cursor: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'cursor'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'after', + }, + revision: { + type: 'string', + }, + cursor: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'revision', 'cursor'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'before', + }, + revision: { + type: 'string', + }, + cursor: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'revision', 'cursor'], + }, + ], + }, + AgentSessionListScope: { + oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'all', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'project', + }, + projectId: { + type: 'string', + format: 'uuid', + }, + }, + additionalProperties: false, + required: ['kind', 'projectId'], + }, + { + description: 'Direct children of an owned session, across workspaces.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'children', + }, + sessionId: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'sessionId'], + }, + { + description: + 'Resolve the exact workspace from an owned session, never a caller path.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'workspace', + }, + sessionId: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'sessionId'], + }, + ], + }, + }, + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentSessionPage', + type: 'object', + properties: { + revision: { + type: 'string', + }, + needsYouCount: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + sessions: { + type: 'array', + items: { + $ref: '#/$defs/AgentSession', + }, + maxItems: 32, + }, + before: { + type: ['string', 'null'], + }, + after: { + type: ['string', 'null'], + }, + }, + additionalProperties: false, + required: ['revision', 'needsYouCount', 'sessions', 'before', 'after'], + $defs: { + AgentSession: { + type: 'object', + properties: { + parentSessionId: { + description: + 'Session that initiated creation, independent of fork ancestry and task ownership.', + type: ['string', 'null'], + }, + branch: { + anyOf: [ + { + $ref: '#/$defs/AgentSessionBranch', + }, + { + type: 'null', + }, + ], + }, + backend: { + anyOf: [ + { + $ref: '#/$defs/AgentBackendPreference', + }, + { + type: 'null', + }, + ], + }, + tools: { + type: ['array', 'null'], + items: { + type: 'string', + }, + }, + dataSource: { + $ref: '#/$defs/AgentDataSource', + }, + permissions: { + $ref: '#/$defs/AgentWorkspacePermissions', + }, + boundaryRevision: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + binding: { + $ref: '#/$defs/BindingStamp', + }, + id: { + type: 'string', + }, + title: { + type: 'string', + }, + labels: { + type: 'array', + items: { + type: 'string', + }, + }, + workspace: { + $ref: '#/$defs/AgentWorkspace', + }, + revision: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + updatedAt: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + archived: { + type: 'boolean', + }, + pinned: { + type: 'boolean', + }, + hasUnread: { + type: 'boolean', + }, + tailMessageId: { + description: + 'Exact durable visible tail accepted by markRead; independent of preview clocks.', + type: ['string', 'null'], + }, + activity: { + $ref: '#/$defs/AgentSessionActivity', + }, + }, + additionalProperties: false, + required: [ + 'parentSessionId', + 'branch', + 'dataSource', + 'permissions', + 'boundaryRevision', + 'binding', + 'id', + 'title', + 'labels', + 'workspace', + 'revision', + 'updatedAt', + 'archived', + 'pinned', + 'hasUnread', + 'tailMessageId', + 'activity', + ], + }, + AgentSessionBranch: { + description: + 'Frozen conversation ancestry for presentation, not authority over the source.', + type: 'object', + properties: { + sessionId: { + type: 'string', + minLength: 1, + }, + position: { + $ref: '#/$defs/AgentForkPosition', + }, + }, + additionalProperties: false, + required: ['sessionId', 'position'], + }, + AgentForkPosition: { + description: 'A complete logical Turn boundary within the source session.', + oneOf: [ + { + description: 'Capture an empty history prefix.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'start', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + description: 'Capture all current history, including later compaction facts.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'end', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + description: 'Include the complete target Turn.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'through_turn', + }, + turnId: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'turnId'], + }, + { + description: + 'Exclude the complete target Turn, including its submitted inputs.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'before_turn', + }, + turnId: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'turnId'], + }, + ], + }, + AgentBackendPreference: { + description: + "The next turn's choice, independent of an already admitted execution.\nModel revisions are resolved afresh; preferences never grant execution authority.", + oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'model', + }, + connectionId: { + type: 'string', + minLength: 1, + }, + modelId: { + type: 'string', + minLength: 1, + }, + thinkingLevel: { + anyOf: [ + { + $ref: '#/$defs/ThinkingLevel', + }, + { + type: 'null', + }, + ], + }, + }, + additionalProperties: false, + required: ['kind', 'connectionId', 'modelId'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'executor', + }, + selection: { + $ref: '#/$defs/AgentExecutorSelection', + }, + }, + additionalProperties: false, + required: ['kind', 'selection'], + }, + ], + }, + ThinkingLevel: { + type: 'string', + enum: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + }, + AgentExecutorSelection: { + type: 'object', + properties: { + executor: { + type: 'string', + minLength: 1, + }, + settings: { + $ref: '#/$defs/ExecutorSettings', + }, + }, + additionalProperties: false, + required: ['executor', 'settings'], + }, + ExecutorSettings: { + description: + 'Executor-owned model selection, independent of Host model connections.', + type: 'object', + properties: { + model: { + type: ['string', 'null'], + }, + thinkingLevel: { + anyOf: [ + { + $ref: '#/$defs/ExecutorEffort', + }, + { + type: 'null', + }, + ], + }, + runtime: { + type: ['string', 'null'], + }, + }, + additionalProperties: false, + }, + ExecutorEffort: { + description: "Runtime-native effort, not a Host model's closed reasoning enum.", + type: 'string', + }, + AgentDataSource: { + description: + 'Stable business-data origin, independent of package or activation revisions.', + type: 'object', + properties: { + stateDomain: { + type: 'string', + }, + deployment: { + type: 'string', + format: 'uuid', + }, + namespace: { + $ref: '#/$defs/InstanceId', + }, + }, + additionalProperties: false, + required: ['stateDomain', 'deployment', 'namespace'], + }, + InstanceId: { + description: + 'Hierarchical, stable source keys. Display aliases never become data identities.', + type: 'array', + items: { + type: 'string', + format: 'uuid', + }, + }, + AgentWorkspacePermissions: { + type: 'object', + properties: { + access: { + $ref: '#/$defs/AgentWorkspaceAccess', + }, + requestPermissions: { + type: 'boolean', + }, + }, + additionalProperties: false, + required: ['access', 'requestPermissions'], + }, + AgentWorkspaceAccess: { + description: + 'Session sandbox policy. Full access disables isolation and permits Computer Use\nwithout additional application, browser or audio consent.', + type: 'string', + enum: ['read_only', 'workspace_write', 'danger_full_access'], + }, + BindingStamp: { + type: 'object', + properties: { + id: { + type: 'string', + format: 'uuid', + }, + permissionEpoch: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['id', 'permissionEpoch'], + }, + AgentWorkspace: { + type: 'object', + properties: { + worktree: { + description: + 'Native owner allocation; never accepted from a caller as workspace authority.', + anyOf: [ + { + $ref: '#/$defs/Binding', + }, + { + type: 'null', + }, + ], + }, + projectId: { + description: "Optional membership in this Agent's canonical Project catalog.", + type: ['string', 'null'], + format: 'uuid', + }, + path: { + type: 'string', + minLength: 1, + }, + directoryIdentity: { + type: 'string', + pattern: '^sha256:[a-f0-9]{64}$', + }, + identity: { + type: 'string', + pattern: '^workspace:v1:[A-Fa-f0-9-]{36}$', + }, + }, + additionalProperties: false, + required: ['path', 'directoryIdentity', 'identity'], + }, + Binding: { + description: 'Immutable allocation intent, not the current child branch or HEAD.', + type: 'object', + properties: { + id: { + type: 'string', + }, + common_dir: { + type: 'string', + }, + source: { + type: 'string', + }, + source_identity: { + type: 'string', + pattern: '^sha256:[a-f0-9]{64}$', + }, + base_commit: { + type: 'string', + }, + directory: { + type: 'string', + }, + }, + additionalProperties: false, + required: [ + 'id', + 'common_dir', + 'source', + 'source_identity', + 'base_commit', + 'directory', + ], + }, + AgentSessionActivity: { + type: 'string', + enum: ['idle', 'working', 'waiting'], + }, + }, + }, + resources: {}, + }, + listFiles: { + mode: 'query', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentFileListRead', + type: 'object', + properties: { + sessionId: { + type: 'string', + }, + path: { + anyOf: [ + { + $ref: '#/$defs/WorkspacePath', + }, + { + type: 'null', + }, + ], + }, + after: { + anyOf: [ + { + $ref: '#/$defs/AgentFileCursor', + }, + { + type: 'null', + }, + ], + }, + }, + additionalProperties: false, + required: ['sessionId', 'path', 'after'], + $defs: { + WorkspacePath: { + type: 'object', + properties: { + bytesBase64: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['bytesBase64'], + }, + AgentFileCursor: { + type: 'object', + properties: { + directoryIdentity: { + type: 'string', + }, + after: { + $ref: '#/$defs/WorkspacePath', + }, + }, + additionalProperties: false, + required: ['directoryIdentity', 'after'], + }, + }, + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentFilePage', + type: 'object', + properties: { + directoryIdentity: { + type: 'string', + }, + entries: { + type: 'array', + items: { + $ref: '#/$defs/AgentFileEntry', + }, + }, + next: { + anyOf: [ + { + $ref: '#/$defs/AgentFileCursor', + }, + { + type: 'null', + }, + ], + }, + }, + additionalProperties: false, + required: ['directoryIdentity', 'entries', 'next'], + $defs: { + AgentFileEntry: { + type: 'object', + properties: { + path: { + $ref: '#/$defs/WorkspacePath', + }, + kind: { + $ref: '#/$defs/AgentFileKind', + }, + }, + additionalProperties: false, + required: ['path', 'kind'], + }, + WorkspacePath: { + type: 'object', + properties: { + bytesBase64: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['bytesBase64'], + }, + AgentFileKind: { + type: 'string', + enum: ['file', 'directory', 'symlink', 'other'], + }, + AgentFileCursor: { + type: 'object', + properties: { + directoryIdentity: { + type: 'string', + }, + after: { + $ref: '#/$defs/WorkspacePath', + }, + }, + additionalProperties: false, + required: ['directoryIdentity', 'after'], + }, + }, + }, + resources: {}, + }, + loadHistoryCheckpoint: { + mode: 'mutation', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentHistoryCheckpointRead', + type: 'object', + properties: { + sessionId: { + type: 'string', + minLength: 1, + }, + revision: { + description: + 'Only the exact currently retained revision is readable; no latest fallback.', + type: 'string', + format: 'uuid', + }, + }, + additionalProperties: false, + required: ['sessionId', 'revision'], + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentCheckpointLoad', + type: 'object', + properties: { + head: { + $ref: '#/$defs/AgentHistoryCheckpointHead', + }, + inspection: { + $ref: '#/$defs/AgentCheckpointInspection', + }, + state: { + $ref: '#/$defs/AgentCheckpointLoadState', + }, + }, + additionalProperties: false, + required: ['head', 'inspection', 'state'], + $defs: { + AgentHistoryCheckpointHead: { + type: 'object', + properties: { + revision: { + type: 'string', + format: 'uuid', + }, + manifest: { + $ref: '#/$defs/AgentHistoryCheckpointObject', + }, + }, + additionalProperties: false, + required: ['revision', 'manifest'], + }, + AgentHistoryCheckpointObject: { + type: 'object', + properties: { + digest: { + type: 'string', + pattern: '^[a-f0-9]{64}$', + }, + bytes: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + }, + additionalProperties: false, + required: ['digest', 'bytes'], + }, + AgentCheckpointInspection: { + type: 'object', + properties: { + sourceSessionId: { + type: 'string', + minLength: 1, + }, + operationId: { + type: 'string', + format: 'uuid', + }, + }, + additionalProperties: false, + required: ['sourceSessionId', 'operationId'], + }, + AgentCheckpointLoadState: { + oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'preparing', }, }, + required: ['kind'], additionalProperties: false, - required: ['kind', 'decision', 'committedAt'], }, { - description: - 'Producer-committed once every requested system grant is present; no answer maps here.', type: 'object', properties: { kind: { type: 'string', - const: 'system_permission_granted', + const: 'ready', }, - committedAt: { - type: 'integer', - format: 'uint64', - minimum: 0, + sessionIds: { + type: 'array', + items: { + type: 'string', + }, + }, + workspace: { + anyOf: [ + { + $ref: '#/$defs/AgentCheckpointWorkspaceSnapshot', + }, + { + type: 'null', + }, + ], }, }, additionalProperties: false, - required: ['kind', 'committedAt'], + required: ['kind', 'sessionIds'], }, { type: 'object', properties: { kind: { type: 'string', - const: 'closure', - }, - reason: { - $ref: '#/$defs/ClosureReason', - }, - committedAt: { - type: 'integer', - format: 'uint64', - minimum: 0, + const: 'unavailable', }, }, + required: ['kind'], additionalProperties: false, - required: ['kind', 'reason', 'committedAt'], }, ], }, - Decision: { - type: 'string', - enum: ['allow', 'deny'], + AgentCheckpointWorkspaceSnapshot: { + type: 'object', + properties: { + identity: { + type: 'string', + }, + consistency: { + $ref: '#/$defs/AgentCheckpointWorkspaceConsistency', + }, + warning: { + $ref: '#/$defs/AgentCheckpointWorkspaceWarning', + }, + inventory: { + $ref: '#/$defs/AgentHistoryCheckpointObject', + }, + }, + additionalProperties: false, + required: ['identity', 'consistency', 'warning', 'inventory'], }, - Decision2: { + AgentCheckpointWorkspaceConsistency: { oneOf: [ { - type: 'object', - properties: { - decision: { - type: 'string', - const: 'deny', - }, - }, - required: ['decision'], - additionalProperties: false, + description: + 'The caller confirms all other writers remain paused for the entire copy.', + type: 'string', + const: 'caller_confirmed_quiescent', }, { - type: 'object', - properties: { - decision: { - type: 'string', - const: 'allow', - }, - permissions: { - $ref: '#/$defs/Permissions', - }, - scope: { - $ref: '#/$defs/Scope2', - }, - }, - additionalProperties: false, - required: ['decision', 'permissions', 'scope'], + description: + 'Files may not represent one instant; successful digest checks do not change this.', + type: 'string', + const: 'unverified', }, ], }, - FormValue: { - anyOf: [ + AgentCheckpointWorkspaceWarning: { + oneOf: [ { + description: + 'Display to users: pause editors, terminals and other writers for the entire\ncapture. Hash verification is not a point-in-time consistency guarantee.', type: 'string', + const: 'external_writers_not_fenced', }, - { - type: 'number', - format: 'double', + ], + }, + }, + }, + resources: {}, + }, + loadedHistoryCheckpointArtifact: { + mode: 'subscription', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentCheckpointArtifactRead', + type: 'object', + properties: { + inspection: { + $ref: '#/$defs/AgentCheckpointInspection', + }, + sessionId: { + type: 'string', + minLength: 1, + }, + artifactId: { + type: 'string', + minLength: 1, + }, + offset: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + }, + additionalProperties: false, + required: ['inspection', 'sessionId', 'artifactId', 'offset'], + $defs: { + AgentCheckpointInspection: { + type: 'object', + properties: { + sourceSessionId: { + type: 'string', + minLength: 1, }, - { - type: 'boolean', + operationId: { + type: 'string', + format: 'uuid', }, - { - type: 'array', - items: { - type: 'string', - }, + }, + additionalProperties: false, + required: ['sourceSessionId', 'operationId'], + }, + }, + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'Nullable_AgentCheckpointArtifactChunk', + anyOf: [ + { + $ref: '#/$defs/AgentCheckpointArtifactChunk', + }, + { + type: 'null', + }, + ], + $defs: { + AgentCheckpointArtifactChunk: { + type: 'object', + properties: { + artifact: { + $ref: '#/$defs/AgentCheckpointArtifact', + }, + offset: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + data: { + description: 'Base64, at most 32 KiB decoded. Empty only at the end.', + type: 'string', + maxLength: 43692, + }, + }, + additionalProperties: false, + required: ['artifact', 'offset', 'data'], + }, + AgentCheckpointArtifact: { + type: 'object', + properties: { + id: { + type: 'string', + }, + name: { + type: 'string', + }, + mimeType: { + type: ['string', 'null'], + }, + bytes: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + }, + additionalProperties: false, + required: ['id', 'name', 'bytes'], + }, + }, + }, + resources: {}, + }, + loadedHistoryCheckpointArtifacts: { + mode: 'query', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentCheckpointArtifactsRead', + type: 'object', + properties: { + inspection: { + $ref: '#/$defs/AgentCheckpointInspection', + }, + sessionId: { + type: 'string', + minLength: 1, + }, + offset: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + }, + additionalProperties: false, + required: ['inspection', 'sessionId', 'offset'], + $defs: { + AgentCheckpointInspection: { + type: 'object', + properties: { + sourceSessionId: { + type: 'string', + minLength: 1, + }, + operationId: { + type: 'string', + format: 'uuid', + }, + }, + additionalProperties: false, + required: ['sourceSessionId', 'operationId'], + }, + }, + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentCheckpointArtifacts', + type: 'object', + properties: { + artifacts: { + type: 'array', + items: { + $ref: '#/$defs/AgentCheckpointArtifact', + }, + }, + nextOffset: { + type: ['integer', 'null'], + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['artifacts', 'nextOffset'], + $defs: { + AgentCheckpointArtifact: { + type: 'object', + properties: { + id: { + type: 'string', + }, + name: { + type: 'string', + }, + mimeType: { + type: ['string', 'null'], + }, + bytes: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, }, - ], - }, - ComputerAppDecision: { - type: 'string', - enum: ['once', 'session', 'always', 'deny'], - }, - ClosureReason: { - type: 'string', - enum: [ - 'steered', - 'turn_stopped', - 'turn_terminal', - 'producer_cancelled', - 'timed_out', - 'host_restarted', - 'provider_disconnected', - ], + }, + additionalProperties: false, + required: ['id', 'name', 'bytes'], }, }, }, resources: {}, }, - list: { + loadedHistoryCheckpointHistory: { mode: 'query', input: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentList', + title: 'AgentCheckpointHistoryRead', type: 'object', properties: { - filter: { - $ref: '#/$defs/AgentSessionFilter', - }, - includeArchived: { - description: 'Include archived conversations alongside active ones.', - type: 'boolean', - default: false, + inspection: { + $ref: '#/$defs/AgentCheckpointInspection', }, - rootsOnly: { - description: - 'Only conversations without a parent; omitted queries still include children.', - type: 'boolean', - default: false, + sessionId: { + type: 'string', + minLength: 1, }, position: { - $ref: '#/$defs/AgentSessionPosition', - }, - scope: { - $ref: '#/$defs/AgentSessionListScope', - default: { - kind: 'all', - }, + $ref: '#/$defs/HistoryPosition', }, - titleQuery: { - description: - 'Literal, case-sensitive title substring; an empty query matches all titles.', - type: 'string', - default: '', + includeThinking: { + type: 'boolean', + default: false, }, }, additionalProperties: false, - required: ['filter', 'position'], + required: ['inspection', 'sessionId', 'position'], $defs: { - AgentSessionFilter: { - type: 'string', - enum: ['all', 'archived', 'needs_you'], + AgentCheckpointInspection: { + type: 'object', + properties: { + sourceSessionId: { + type: 'string', + minLength: 1, + }, + operationId: { + type: 'string', + format: 'uuid', + }, + }, + additionalProperties: false, + required: ['sourceSessionId', 'operationId'], }, - AgentSessionPosition: { + HistoryPosition: { + description: + 'Row reveals an exact field; Before/After continue at byte boundaries and\nEvent addresses the raw event log. Returned chunks retain ascending presentation\norder, including a reverse window.', oneOf: [ { type: 'object', @@ -11496,33 +13243,30 @@ export const contracts = { properties: { kind: { type: 'string', - const: 'at', - }, - cursor: { - type: 'string', - minLength: 1, + const: 'tail', }, }, + required: ['kind'], additionalProperties: false, - required: ['kind', 'cursor'], }, { + description: + 'Reveal this exact row field from its beginning, including empty tool rows.', type: 'object', properties: { kind: { type: 'string', - const: 'after', + const: 'row', }, - revision: { - type: 'string', + position: { + $ref: '#/$defs/HistoryRowPosition', }, - cursor: { - type: 'string', - minLength: 1, + field: { + $ref: '#/$defs/HistoryField', }, }, additionalProperties: false, - required: ['kind', 'revision', 'cursor'], + required: ['kind', 'position', 'field'], }, { type: 'object', @@ -11531,666 +13275,745 @@ export const contracts = { type: 'string', const: 'before', }, - revision: { - type: 'string', - }, cursor: { - type: 'string', - minLength: 1, - }, - }, - additionalProperties: false, - required: ['kind', 'revision', 'cursor'], - }, - ], - }, - AgentSessionListScope: { - oneOf: [ - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'all', - }, - }, - required: ['kind'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'project', - }, - projectId: { - type: 'string', - format: 'uuid', + $ref: '#/$defs/Cursor', }, }, additionalProperties: false, - required: ['kind', 'projectId'], + required: ['kind', 'cursor'], }, { - description: 'Direct children of an owned session, across workspaces.', type: 'object', properties: { kind: { type: 'string', - const: 'children', + const: 'after', }, - sessionId: { - type: 'string', - minLength: 1, + cursor: { + $ref: '#/$defs/Cursor', }, }, additionalProperties: false, - required: ['kind', 'sessionId'], + required: ['kind', 'cursor'], }, { description: - 'Resolve the exact workspace from an owned session, never a caller path.', + 'Locate canonical output at an execution event, including sparse rows.', type: 'object', properties: { kind: { type: 'string', - const: 'workspace', + const: 'event', }, - sessionId: { - type: 'string', - minLength: 1, + sequence: { + type: 'integer', + format: 'uint64', + minimum: 1, + maximum: 9007199254740991, }, }, additionalProperties: false, - required: ['kind', 'sessionId'], + required: ['kind', 'sequence'], }, ], }, + HistoryRowPosition: { + description: + 'Disposable presentation locator, never authority or a canonical execution fact.\nThe source is the original emitting fact; the index precedes UI filtering.', + type: 'object', + properties: { + sourceSequence: { + type: 'integer', + format: 'uint64', + minimum: 1, + maximum: 9007199254740991, + }, + itemIndex: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + }, + additionalProperties: false, + required: ['sourceSequence', 'itemIndex'], + }, + HistoryField: { + description: 'A readable field in a conversation window, in presentation order.', + type: 'string', + enum: ['thinking', 'body'], + }, + Cursor: { + description: + "Byte offset in one message field's UTF-8 text, not a bearer capability.", + type: 'object', + properties: { + field: { + $ref: '#/$defs/HistoryField', + default: 'body', + }, + position: { + $ref: '#/$defs/HistoryRowPosition', + }, + offset: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + }, + additionalProperties: false, + required: ['position', 'offset'], + }, }, }, output: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentSessionPage', - type: 'object', - properties: { - revision: { - type: 'string', - }, - needsYouCount: { - type: 'integer', - format: 'uint64', - minimum: 0, - }, - sessions: { - type: 'array', - items: { - $ref: '#/$defs/AgentSession', + title: 'AgentHistoryPage', + oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'ready', + }, + through: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + chunks: { + type: 'array', + items: { + $ref: '#/$defs/AgentHistoryChunk', + }, + }, + before: { + anyOf: [ + { + $ref: '#/$defs/Cursor', + }, + { + type: 'null', + }, + ], + }, + after: { + anyOf: [ + { + $ref: '#/$defs/Cursor', + }, + { + type: 'null', + }, + ], + }, }, - maxItems: 32, - }, - before: { - type: ['string', 'null'], - }, - after: { - type: ['string', 'null'], + additionalProperties: false, + required: ['kind', 'through', 'chunks', 'before', 'after'], }, - }, - additionalProperties: false, - required: ['revision', 'needsYouCount', 'sessions', 'before', 'after'], + ], $defs: { - AgentSession: { + AgentHistoryChunk: { type: 'object', properties: { - parentSessionId: { + modelPart: { + anyOf: [ + { + $ref: '#/$defs/ModelPartIdentity', + }, + { + type: 'null', + }, + ], + }, + hasReferences: { description: - 'Session that initiated creation, independent of fork ancestry and task ownership.', - type: ['string', 'null'], + 'The original user input has quotes, directory references or inline references.\nPresent only on the first fragment; display text cannot reconstruct these references.', + type: 'boolean', }, - branch: { + finalOutput: { + description: + "Part of the successfully completed native Turn's final output at `through`.\nIncludes all body parts and source citations; never a Thinking field.", + type: 'boolean', + }, + model: { anyOf: [ { - $ref: '#/$defs/AgentSessionBranch', + $ref: '#/$defs/HistoryModel', }, { type: 'null', }, ], }, - backend: { + origin: { anyOf: [ { - $ref: '#/$defs/AgentBackendPreference', + $ref: '#/$defs/MessageOrigin', }, { type: 'null', }, ], }, - tools: { - type: ['array', 'null'], + compaction: { + anyOf: [ + { + $ref: '#/$defs/HistoryCompaction', + }, + { + type: 'null', + }, + ], + }, + attachments: { + description: + 'Display metadata only, on the first user-message fragment; no storage capability.', + type: 'array', items: { - type: 'string', + $ref: '#/$defs/AgentHistoryAttachment', }, }, - dataSource: { - $ref: '#/$defs/AgentDataSource', + timing: { + anyOf: [ + { + $ref: '#/$defs/HistoryTurnTiming', + }, + { + type: 'null', + }, + ], }, - permissions: { - $ref: '#/$defs/AgentWorkspacePermissions', + field: { + $ref: '#/$defs/HistoryField', }, - boundaryRevision: { + messageId: { + type: 'string', + }, + turnId: { + type: 'string', + }, + timestamp: { type: 'integer', format: 'uint64', minimum: 0, }, - binding: { - $ref: '#/$defs/BindingStamp', - }, - id: { - type: 'string', - }, - title: { - type: 'string', + role: { + $ref: '#/$defs/Role', }, - labels: { - type: 'array', - items: { - type: 'string', - }, + order: { + description: + 'Stable display order; position remains the canonical source read address.', + $ref: '#/$defs/HistoryRowPosition', }, - workspace: { - $ref: '#/$defs/AgentWorkspace', + position: { + $ref: '#/$defs/HistoryRowPosition', }, - revision: { + offset: { type: 'integer', format: 'uint64', minimum: 0, }, - updatedAt: { + totalBytes: { type: 'integer', format: 'uint64', minimum: 0, }, - archived: { - type: 'boolean', - }, - pinned: { - type: 'boolean', - }, - hasUnread: { - type: 'boolean', - }, - tailMessageId: { - description: - 'Exact durable visible tail accepted by markRead; independent of preview clocks.', - type: ['string', 'null'], + text: { + type: 'string', }, - activity: { - $ref: '#/$defs/AgentSessionActivity', + tool: { + anyOf: [ + { + $ref: '#/$defs/HistoryTool', + }, + { + type: 'null', + }, + ], }, }, additionalProperties: false, required: [ - 'parentSessionId', - 'branch', - 'dataSource', - 'permissions', - 'boundaryRevision', - 'binding', - 'id', - 'title', - 'labels', - 'workspace', - 'revision', - 'updatedAt', - 'archived', - 'pinned', - 'hasUnread', - 'tailMessageId', - 'activity', + 'field', + 'messageId', + 'turnId', + 'timestamp', + 'role', + 'order', + 'position', + 'offset', + 'totalBytes', + 'text', + 'tool', ], }, - AgentSessionBranch: { - description: - 'Frozen conversation ancestry for presentation, not authority over the source.', + ModelPartIdentity: { type: 'object', properties: { - sessionId: { + stepId: { type: 'string', - minLength: 1, }, - position: { - $ref: '#/$defs/AgentForkPosition', + ordinal: { + type: 'integer', + format: 'uint64', + minimum: 0, }, }, additionalProperties: false, - required: ['sessionId', 'position'], + required: ['stepId', 'ordinal'], }, - AgentForkPosition: { - description: 'A complete logical Turn boundary within the source session.', + HistoryModel: { + description: 'Model identity recorded for the exact historical assistant step.', oneOf: [ { - description: 'Capture an empty history prefix.', type: 'object', properties: { kind: { type: 'string', - const: 'start', + const: 'native', + }, + requestedModelId: { + type: 'string', + }, + reportedModelId: { + type: ['string', 'null'], }, }, - required: ['kind'], additionalProperties: false, + required: ['kind', 'requestedModelId', 'reportedModelId'], }, { - description: 'Capture all current history, including later compaction facts.', type: 'object', properties: { kind: { type: 'string', - const: 'end', + const: 'imported', + }, + modelId: { + type: 'string', }, }, - required: ['kind'], additionalProperties: false, + required: ['kind', 'modelId'], }, + ], + }, + MessageOrigin: { + description: + 'Trusted producer attribution, preserved independently of editable content.', + oneOf: [ { - description: 'Include the complete target Turn.', type: 'object', properties: { kind: { type: 'string', - const: 'through_turn', + const: 'session', }, - turnId: { + invocation: { + $ref: '#/$defs/Invocation', + }, + }, + additionalProperties: false, + required: ['kind', 'invocation'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'goal', + }, + goalId: { + type: 'string', + }, + objective: { type: 'string', - minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'turnId'], + required: ['kind', 'goalId', 'objective'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'workhub_task', + }, + taskId: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['kind', 'taskId'], }, { - description: - 'Exclude the complete target Turn, including its submitted inputs.', type: 'object', properties: { kind: { type: 'string', - const: 'before_turn', + const: 'workhub_graph', }, - turnId: { + graphId: { type: 'string', - minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'turnId'], + required: ['kind', 'graphId'], }, - ], - }, - AgentBackendPreference: { - description: - "The next turn's choice, independent of an already admitted execution.\nModel revisions are resolved afresh; preferences never grant execution authority.", - oneOf: [ { + description: 'A worker conversation created by the Bot finished a run.', type: 'object', properties: { kind: { type: 'string', - const: 'model', - }, - connectionId: { - type: 'string', - minLength: 1, + const: 'bot_worker', }, - modelId: { + sessionId: { type: 'string', - minLength: 1, - }, - thinkingLevel: { - anyOf: [ - { - $ref: '#/$defs/ThinkingLevel', - }, - { - type: 'null', - }, - ], }, }, additionalProperties: false, - required: ['kind', 'connectionId', 'modelId'], + required: ['kind', 'sessionId'], }, { + description: + "The opening message of an approved Plan execution. `title` is the\napproved Plan's title when the execution was frozen, not the current Plan.", type: 'object', properties: { kind: { type: 'string', - const: 'executor', + const: 'plan', }, - selection: { - $ref: '#/$defs/AgentExecutorSelection', + executionId: { + type: 'string', + }, + title: { + type: 'string', }, }, additionalProperties: false, - required: ['kind', 'selection'], + required: ['kind', 'executionId', 'title'], }, ], }, - ThinkingLevel: { - type: 'string', - enum: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], - }, - AgentExecutorSelection: { + Invocation: { type: 'object', properties: { - executor: { + session_id: { type: 'string', - minLength: 1, }, - settings: { - $ref: '#/$defs/ExecutorSettings', + turn_id: { + type: 'string', + }, + run_id: { + type: 'string', + }, + invocation_id: { + type: 'string', }, }, - additionalProperties: false, - required: ['executor', 'settings'], + required: ['session_id', 'turn_id', 'run_id', 'invocation_id'], }, - ExecutorSettings: { + HistoryCompaction: { description: - 'Executor-owned model selection, independent of Host model connections.', + 'Duration of a successfully adopted context checkpoint, in recorded milliseconds.', type: 'object', properties: { - model: { - type: ['string', 'null'], - }, - thinkingLevel: { - anyOf: [ - { - $ref: '#/$defs/ExecutorEffort', - }, - { - type: 'null', - }, - ], - }, - runtime: { - type: ['string', 'null'], + durationMs: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, }, }, additionalProperties: false, + required: ['durationMs'], }, - ExecutorEffort: { - description: "Runtime-native effort, not a Host model's closed reasoning enum.", - type: 'string', - }, - AgentDataSource: { - description: - 'Stable business-data origin, independent of package or activation revisions.', + AgentHistoryAttachment: { type: 'object', properties: { - stateDomain: { + name: { type: 'string', }, - deployment: { + mimeType: { type: 'string', - format: 'uuid', - }, - namespace: { - $ref: '#/$defs/InstanceId', }, - }, - additionalProperties: false, - required: ['stateDomain', 'deployment', 'namespace'], - }, - InstanceId: { - description: - 'Hierarchical, stable source keys. Display aliases never become data identities.', - type: 'array', - items: { - type: 'string', - format: 'uuid', - }, - }, - AgentWorkspacePermissions: { - type: 'object', - properties: { - access: { - $ref: '#/$defs/AgentWorkspaceAccess', + bytes: { + type: 'integer', + format: 'uint64', + minimum: 0, }, - requestPermissions: { - type: 'boolean', + kind: { + $ref: '#/$defs/AttachmentKind', }, }, additionalProperties: false, - required: ['access', 'requestPermissions'], + required: ['name', 'mimeType', 'bytes', 'kind'], }, - AgentWorkspaceAccess: { - description: - 'Session sandbox policy. Full access disables isolation and permits Computer Use\nwithout additional application, browser or audio consent.', + AttachmentKind: { type: 'string', - enum: ['read_only', 'workspace_write', 'danger_full_access'], + enum: ['image', 'pdf', 'doc', 'code', 'other'], }, - BindingStamp: { + HistoryTurnTiming: { + description: + 'Recorded logical Turn boundaries at the same history fence. An unfinished\nhistorical boundary alone does not establish that execution is still live.', type: 'object', properties: { - id: { - type: 'string', - format: 'uuid', - }, - permissionEpoch: { + startedAt: { type: 'integer', format: 'uint64', minimum: 0, + maximum: 9007199254740991, }, - }, - additionalProperties: false, - required: ['id', 'permissionEpoch'], - }, - AgentWorkspace: { - type: 'object', - properties: { - worktree: { - description: - 'Native owner allocation; never accepted from a caller as workspace authority.', + finished: { anyOf: [ { - $ref: '#/$defs/Binding', + $ref: '#/$defs/HistoryTurnFinished', }, { type: 'null', }, ], }, - projectId: { - description: "Optional membership in this Agent's canonical Project catalog.", - type: ['string', 'null'], - format: 'uuid', - }, - path: { - type: 'string', - minLength: 1, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - identity: { - type: 'string', - pattern: '^workspace:v1:[A-Fa-f0-9-]{36}$', - }, }, additionalProperties: false, - required: ['path', 'directoryIdentity', 'identity'], + required: ['startedAt', 'finished'], }, - Binding: { - description: 'Immutable allocation intent, not the current child branch or HEAD.', + HistoryTurnFinished: { type: 'object', properties: { - id: { - type: 'string', - }, - common_dir: { - type: 'string', - }, - source: { - type: 'string', - }, - source_identity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - base_commit: { - type: 'string', + at: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, }, - directory: { - type: 'string', + outcome: { + $ref: '#/$defs/HistoryTurnOutcome', }, }, additionalProperties: false, - required: [ - 'id', - 'common_dir', - 'source', - 'source_identity', - 'base_commit', - 'directory', - ], + required: ['at', 'outcome'], }, - AgentSessionActivity: { + HistoryTurnOutcome: { type: 'string', - enum: ['idle', 'working', 'waiting'], + enum: ['completed', 'failed', 'aborted'], }, - }, - }, - resources: {}, - }, - listFiles: { - mode: 'query', - input: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentFileListRead', - type: 'object', - properties: { - sessionId: { + HistoryField: { + description: 'A readable field in a conversation window, in presentation order.', type: 'string', + enum: ['thinking', 'body'], }, - path: { - anyOf: [ - { - $ref: '#/$defs/WorkspacePath', - }, - { - type: 'null', - }, - ], + Role: { + description: + 'Canonical message author/kind; the field identifies body versus thinking.\nTool body JSON contributes string values, never its keys.', + type: 'string', + enum: ['compaction', 'failure', 'user', 'assistant', 'tool_call', 'tool_result'], }, - after: { - anyOf: [ - { - $ref: '#/$defs/AgentFileCursor', + HistoryRowPosition: { + description: + 'Disposable presentation locator, never authority or a canonical execution fact.\nThe source is the original emitting fact; the index precedes UI filtering.', + type: 'object', + properties: { + sourceSequence: { + type: 'integer', + format: 'uint64', + minimum: 1, + maximum: 9007199254740991, }, - { - type: 'null', + itemIndex: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, }, - ], + }, + additionalProperties: false, + required: ['sourceSequence', 'itemIndex'], }, - }, - additionalProperties: false, - required: ['sessionId', 'path', 'after'], - $defs: { - WorkspacePath: { + HistoryTool: { + description: + 'Canonical references at the requested fence, never execution authority.\nBoth references are present only for a unique same-Turn call/result pair\nwith matching origin and parentage. An unpaired row retains its own reference.', type: 'object', properties: { - bytesBase64: { + toolUseId: { type: 'string', }, + call: { + anyOf: [ + { + $ref: '#/$defs/HistoryToolCall', + }, + { + type: 'null', + }, + ], + }, + result: { + anyOf: [ + { + $ref: '#/$defs/HistoryToolResult', + }, + { + type: 'null', + }, + ], + }, }, additionalProperties: false, - required: ['bytesBase64'], + required: ['toolUseId', 'call', 'result'], }, - AgentFileCursor: { + HistoryToolCall: { type: 'object', properties: { - directoryIdentity: { + ancestorToolUseIds: { + description: + 'Recorded ancestors in this Turn, nearest first; display references only.', + type: 'array', + items: { + type: 'string', + }, + }, + origin: { + $ref: '#/$defs/HistoryToolCallOrigin', + }, + presentation: { + description: + 'Opaque display contract captured at dispatch; no execution authority.', + type: ['string', 'null'], + default: null, + }, + position: { + $ref: '#/$defs/HistoryRowPosition', + }, + name: { type: 'string', }, - after: { - $ref: '#/$defs/WorkspacePath', + title: { + anyOf: [ + { + $ref: '#/$defs/DisplayText', + }, + { + type: 'null', + }, + ], + }, + pending: { + description: + 'This native Turn is still open at the history fence; not execution authority.', + type: 'boolean', + }, + dispatched: { + description: + 'A unique original Host dispatch is recorded at this fence; not liveness.', + type: 'boolean', }, }, additionalProperties: false, - required: ['directoryIdentity', 'after'], + required: [ + 'ancestorToolUseIds', + 'origin', + 'position', + 'name', + 'title', + 'pending', + 'dispatched', + ], }, - }, - }, - output: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentFilePage', - type: 'object', - properties: { - directoryIdentity: { + HistoryToolCallOrigin: { + description: + "Who made a call: a model step, a script the model wrote, a tool's own work\nthrough the Host SDK, or a foreign record.", type: 'string', + enum: ['provider', 'code_mode', 'host_sdk', 'imported'], }, - entries: { - type: 'array', - items: { - $ref: '#/$defs/AgentFileEntry', - }, - }, - next: { - anyOf: [ - { - $ref: '#/$defs/AgentFileCursor', - }, - { - type: 'null', - }, - ], - }, - }, - additionalProperties: false, - required: ['directoryIdentity', 'entries', 'next'], - $defs: { - AgentFileEntry: { + DisplayText: { + description: + 'Presentation text is distinct from the stable identity used for navigation.', type: 'object', properties: { - path: { - $ref: '#/$defs/WorkspacePath', + fallback: { + type: 'string', }, - kind: { - $ref: '#/$defs/AgentFileKind', + translations: { + type: 'object', + additionalProperties: { + type: 'string', + }, + default: {}, }, }, additionalProperties: false, - required: ['path', 'kind'], + required: ['fallback'], }, - WorkspacePath: { + HistoryToolResult: { type: 'object', properties: { - bytesBase64: { - type: 'string', + settlement: { + anyOf: [ + { + $ref: '#/$defs/ToolSettlement', + }, + { + type: 'null', + }, + ], + }, + position: { + $ref: '#/$defs/HistoryRowPosition', + }, + isError: { + description: + 'A failed or unknown effect, or a returned result that reports failure.', + type: 'boolean', + }, + image: { + description: + 'Canonical content is an image; this does not grant artifact reads.', + type: 'boolean', + }, + images: { + description: + 'Images the model received beside non-image content, readable by index.', + type: 'integer', + format: 'uint32', + minimum: 0, }, }, additionalProperties: false, - required: ['bytesBase64'], + required: ['settlement', 'position', 'isError', 'image', 'images'], }, - AgentFileKind: { + ToolSettlement: { + description: + 'Recorded Host settlement for presentation; returned is not business success.\nForeign/provider results do not establish this Host evidence.', type: 'string', - enum: ['file', 'directory', 'symlink', 'other'], + enum: ['returned', 'failed', 'unknown', 'rejected'], }, - AgentFileCursor: { + Cursor: { + description: + "Byte offset in one message field's UTF-8 text, not a bearer capability.", type: 'object', properties: { - directoryIdentity: { - type: 'string', + field: { + $ref: '#/$defs/HistoryField', + default: 'body', }, - after: { - $ref: '#/$defs/WorkspacePath', + position: { + $ref: '#/$defs/HistoryRowPosition', + }, + offset: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, }, }, additionalProperties: false, - required: ['directoryIdentity', 'after'], + required: ['position', 'offset'], }, }, }, @@ -15687,11 +17510,11 @@ export const contracts = { }, resources: {}, }, - publishHistoryCheckpoint: { + publishCheckpoint: { mode: 'mutation', input: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentHistoryCheckpointPublish', + title: 'AgentCheckpointPublish', type: 'object', properties: { sessionId: { @@ -15708,13 +17531,48 @@ export const contracts = { type: 'string', pattern: '^[a-f0-9]{64}$', }, + workspaceConsistency: { + description: + 'Null captures history only. Otherwise the caller explicitly selects the\nWorkspace consistency guarantee; other applications are never fenced.', + anyOf: [ + { + $ref: '#/$defs/AgentCheckpointWorkspaceConsistency', + }, + { + type: 'null', + }, + ], + }, }, additionalProperties: false, - required: ['sessionId', 'expectedRevision', 'confirmedSubtreeDigest'], + required: [ + 'sessionId', + 'expectedRevision', + 'confirmedSubtreeDigest', + 'workspaceConsistency', + ], + $defs: { + AgentCheckpointWorkspaceConsistency: { + oneOf: [ + { + description: + 'The caller confirms all other writers remain paused for the entire copy.', + type: 'string', + const: 'caller_confirmed_quiescent', + }, + { + description: + 'Files may not represent one instant; successful digest checks do not change this.', + type: 'string', + const: 'unverified', + }, + ], + }, + }, }, output: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentHistoryCheckpointPublication', + title: 'AgentCheckpointPublication', type: 'object', properties: { state: { @@ -15724,6 +17582,16 @@ export const contracts = { description: 'Cleanup failure does not undo a successful publication.', type: 'boolean', }, + workspace: { + anyOf: [ + { + $ref: '#/$defs/AgentCheckpointWorkspaceNotice', + }, + { + type: 'null', + }, + ], + }, }, additionalProperties: false, required: ['state', 'cleanupPending'], @@ -15836,6 +17704,45 @@ export const contracts = { additionalProperties: false, required: ['digest', 'bytes'], }, + AgentCheckpointWorkspaceNotice: { + type: 'object', + properties: { + consistency: { + $ref: '#/$defs/AgentCheckpointWorkspaceConsistency', + }, + warning: { + $ref: '#/$defs/AgentCheckpointWorkspaceWarning', + }, + }, + additionalProperties: false, + required: ['consistency', 'warning'], + }, + AgentCheckpointWorkspaceConsistency: { + oneOf: [ + { + description: + 'The caller confirms all other writers remain paused for the entire copy.', + type: 'string', + const: 'caller_confirmed_quiescent', + }, + { + description: + 'Files may not represent one instant; successful digest checks do not change this.', + type: 'string', + const: 'unverified', + }, + ], + }, + AgentCheckpointWorkspaceWarning: { + oneOf: [ + { + description: + 'Display to users: pause editors, terminals and other writers for the entire\ncapture. Hash verification is not a point-in-time consistency guarantee.', + type: 'string', + const: 'external_writers_not_fenced', + }, + ], + }, }, }, resources: {}, @@ -16370,12 +18277,13 @@ export const contracts = { AgentHistoryCheckpointFrame: { oneOf: [ { - description: 'The following bytes use the MAKA-SESSION v2 history format.', + description: + 'MAKA-SESSION v2 history bytes follow; Workspace objects are separate.', type: 'object', properties: { kind: { type: 'string', - const: 'native_history_v1', + const: 'checkpoint', }, head: { $ref: '#/$defs/AgentHistoryCheckpointHead', @@ -16386,6 +18294,16 @@ export const contracts = { bundle: { $ref: '#/$defs/AgentHistoryCheckpointObject', }, + workspace: { + anyOf: [ + { + $ref: '#/$defs/AgentCheckpointWorkspaceSnapshot', + }, + { + type: 'null', + }, + ], + }, }, additionalProperties: false, required: ['kind', 'head', 'inventory', 'bundle'], @@ -16500,6 +18418,51 @@ export const contracts = { additionalProperties: false, required: ['id', 'revision'], }, + AgentCheckpointWorkspaceSnapshot: { + type: 'object', + properties: { + identity: { + type: 'string', + }, + consistency: { + $ref: '#/$defs/AgentCheckpointWorkspaceConsistency', + }, + warning: { + $ref: '#/$defs/AgentCheckpointWorkspaceWarning', + }, + inventory: { + $ref: '#/$defs/AgentHistoryCheckpointObject', + }, + }, + additionalProperties: false, + required: ['identity', 'consistency', 'warning', 'inventory'], + }, + AgentCheckpointWorkspaceConsistency: { + oneOf: [ + { + description: + 'The caller confirms all other writers remain paused for the entire copy.', + type: 'string', + const: 'caller_confirmed_quiescent', + }, + { + description: + 'Files may not represent one instant; successful digest checks do not change this.', + type: 'string', + const: 'unverified', + }, + ], + }, + AgentCheckpointWorkspaceWarning: { + oneOf: [ + { + description: + 'Display to users: pause editors, terminals and other writers for the entire\ncapture. Hash verification is not a point-in-time consistency guarantee.', + type: 'string', + const: 'external_writers_not_fenced', + }, + ], + }, }, }, resources: {}, @@ -18798,166 +20761,447 @@ export const contracts = { ], required: ['kind', 'committedAt'], }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'client_capability_decision', - }, - decision: { - $ref: '#/$defs/Decision', - }, - committedAt: { - type: 'integer', - format: 'uint64', - minimum: 0, - }, - }, - additionalProperties: false, - required: ['kind', 'decision', 'committedAt'], + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'client_capability_decision', + }, + decision: { + $ref: '#/$defs/Decision', + }, + committedAt: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'decision', 'committedAt'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'computer_app_decision', + }, + decision: { + $ref: '#/$defs/ComputerAppDecision', + }, + committedAt: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'decision', 'committedAt'], + }, + { + description: + 'Producer-committed once every requested system grant is present; no answer maps here.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'system_permission_granted', + }, + committedAt: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'committedAt'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'closure', + }, + reason: { + $ref: '#/$defs/ClosureReason', + }, + committedAt: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'reason', 'committedAt'], + }, + ], + }, + Decision: { + type: 'string', + enum: ['allow', 'deny'], + }, + Decision2: { + oneOf: [ + { + type: 'object', + properties: { + decision: { + type: 'string', + const: 'deny', + }, + }, + required: ['decision'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + decision: { + type: 'string', + const: 'allow', + }, + permissions: { + $ref: '#/$defs/Permissions', + }, + scope: { + $ref: '#/$defs/Scope2', + }, + }, + additionalProperties: false, + required: ['decision', 'permissions', 'scope'], + }, + ], + }, + FormValue: { + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + format: 'double', + }, + { + type: 'boolean', + }, + { + type: 'array', + items: { + type: 'string', + }, + }, + ], + }, + ComputerAppDecision: { + type: 'string', + enum: ['once', 'session', 'always', 'deny'], + }, + ClosureReason: { + type: 'string', + enum: [ + 'steered', + 'turn_stopped', + 'turn_terminal', + 'producer_cancelled', + 'timed_out', + 'host_restarted', + 'provider_disconnected', + ], + }, + AgentInteractionRejection: { + type: 'string', + enum: [ + 'not_your_child', + 'human_action_required', + 'authorization_changed', + 'permission_requests_disabled', + 'closed', + 'unavailable', + 'invalid_answer', + 'unsupported', + ], + }, + }, + }, + resources: {}, + }, + restoreSessionCheckpoint: { + mode: 'mutation', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentSessionCheckpointRestore', + description: + 'Restore files into a new child of a selected directory and rebind the same,\nunchanged local Session. It never starts a model/tool call or rewinds history.', + type: 'object', + properties: { + checkpoint: { + $ref: '#/$defs/AgentHistoryCheckpointRead', + }, + parent: { + description: + 'Absolute parent path. The Host captures and validates its directory identity.', + type: 'string', + minLength: 1, + }, + permissions: { + $ref: '#/$defs/AgentWorkspacePermissions', + }, + backend: { + $ref: '#/$defs/AgentBackendPreference', + }, + acceptExternalWritersRisk: { + type: 'boolean', + }, + }, + additionalProperties: false, + required: [ + 'checkpoint', + 'parent', + 'permissions', + 'backend', + 'acceptExternalWritersRisk', + ], + $defs: { + AgentHistoryCheckpointRead: { + type: 'object', + properties: { + sessionId: { + type: 'string', + minLength: 1, + }, + revision: { + description: + 'Only the exact currently retained revision is readable; no latest fallback.', + type: 'string', + format: 'uuid', }, + }, + additionalProperties: false, + required: ['sessionId', 'revision'], + }, + AgentWorkspacePermissions: { + type: 'object', + properties: { + access: { + $ref: '#/$defs/AgentWorkspaceAccess', + }, + requestPermissions: { + type: 'boolean', + }, + }, + additionalProperties: false, + required: ['access', 'requestPermissions'], + }, + AgentWorkspaceAccess: { + description: + 'Session sandbox policy. Full access disables isolation and permits Computer Use\nwithout additional application, browser or audio consent.', + type: 'string', + enum: ['read_only', 'workspace_write', 'danger_full_access'], + }, + AgentBackendPreference: { + description: + "The next turn's choice, independent of an already admitted execution.\nModel revisions are resolved afresh; preferences never grant execution authority.", + oneOf: [ { type: 'object', properties: { kind: { type: 'string', - const: 'computer_app_decision', - }, - decision: { - $ref: '#/$defs/ComputerAppDecision', + const: 'model', }, - committedAt: { - type: 'integer', - format: 'uint64', - minimum: 0, + connectionId: { + type: 'string', + minLength: 1, }, - }, - additionalProperties: false, - required: ['kind', 'decision', 'committedAt'], - }, - { - description: - 'Producer-committed once every requested system grant is present; no answer maps here.', - type: 'object', - properties: { - kind: { + modelId: { type: 'string', - const: 'system_permission_granted', + minLength: 1, }, - committedAt: { - type: 'integer', - format: 'uint64', - minimum: 0, + thinkingLevel: { + anyOf: [ + { + $ref: '#/$defs/ThinkingLevel', + }, + { + type: 'null', + }, + ], }, }, additionalProperties: false, - required: ['kind', 'committedAt'], + required: ['kind', 'connectionId', 'modelId'], }, { type: 'object', properties: { kind: { type: 'string', - const: 'closure', - }, - reason: { - $ref: '#/$defs/ClosureReason', + const: 'executor', }, - committedAt: { - type: 'integer', - format: 'uint64', - minimum: 0, + selection: { + $ref: '#/$defs/AgentExecutorSelection', }, }, additionalProperties: false, - required: ['kind', 'reason', 'committedAt'], + required: ['kind', 'selection'], }, ], }, - Decision: { + ThinkingLevel: { type: 'string', - enum: ['allow', 'deny'], + enum: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], }, - Decision2: { - oneOf: [ - { - type: 'object', - properties: { - decision: { - type: 'string', - const: 'deny', - }, - }, - required: ['decision'], - additionalProperties: false, + AgentExecutorSelection: { + type: 'object', + properties: { + executor: { + type: 'string', + minLength: 1, }, - { - type: 'object', - properties: { - decision: { - type: 'string', - const: 'allow', - }, - permissions: { - $ref: '#/$defs/Permissions', + settings: { + $ref: '#/$defs/ExecutorSettings', + }, + }, + additionalProperties: false, + required: ['executor', 'settings'], + }, + ExecutorSettings: { + description: + 'Executor-owned model selection, independent of Host model connections.', + type: 'object', + properties: { + model: { + type: ['string', 'null'], + }, + thinkingLevel: { + anyOf: [ + { + $ref: '#/$defs/ExecutorEffort', }, - scope: { - $ref: '#/$defs/Scope2', + { + type: 'null', }, - }, - additionalProperties: false, - required: ['decision', 'permissions', 'scope'], + ], }, - ], + runtime: { + type: ['string', 'null'], + }, + }, + additionalProperties: false, }, - FormValue: { - anyOf: [ - { + ExecutorEffort: { + description: "Runtime-native effort, not a Host model's closed reasoning enum.", + type: 'string', + }, + }, + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentSessionCheckpointRestored', + type: 'object', + properties: { + operation: { + type: 'string', + format: 'uuid', + }, + sessionId: { + type: 'string', + }, + sessionRevision: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + head: { + $ref: '#/$defs/AgentHistoryCheckpointHead', + }, + workspace: { + $ref: '#/$defs/AgentCheckpointWorkspaceSnapshot', + }, + path: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['operation', 'sessionId', 'sessionRevision', 'head', 'workspace', 'path'], + $defs: { + AgentHistoryCheckpointHead: { + type: 'object', + properties: { + revision: { type: 'string', + format: 'uuid', }, - { - type: 'number', - format: 'double', + manifest: { + $ref: '#/$defs/AgentHistoryCheckpointObject', + }, + }, + additionalProperties: false, + required: ['revision', 'manifest'], + }, + AgentHistoryCheckpointObject: { + type: 'object', + properties: { + digest: { + type: 'string', + pattern: '^[a-f0-9]{64}$', + }, + bytes: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + }, + additionalProperties: false, + required: ['digest', 'bytes'], + }, + AgentCheckpointWorkspaceSnapshot: { + type: 'object', + properties: { + identity: { + type: 'string', + }, + consistency: { + $ref: '#/$defs/AgentCheckpointWorkspaceConsistency', + }, + warning: { + $ref: '#/$defs/AgentCheckpointWorkspaceWarning', + }, + inventory: { + $ref: '#/$defs/AgentHistoryCheckpointObject', }, + }, + additionalProperties: false, + required: ['identity', 'consistency', 'warning', 'inventory'], + }, + AgentCheckpointWorkspaceConsistency: { + oneOf: [ { - type: 'boolean', + description: + 'The caller confirms all other writers remain paused for the entire copy.', + type: 'string', + const: 'caller_confirmed_quiescent', }, { - type: 'array', - items: { - type: 'string', - }, + description: + 'Files may not represent one instant; successful digest checks do not change this.', + type: 'string', + const: 'unverified', }, ], }, - ComputerAppDecision: { - type: 'string', - enum: ['once', 'session', 'always', 'deny'], - }, - ClosureReason: { - type: 'string', - enum: [ - 'steered', - 'turn_stopped', - 'turn_terminal', - 'producer_cancelled', - 'timed_out', - 'host_restarted', - 'provider_disconnected', - ], - }, - AgentInteractionRejection: { - type: 'string', - enum: [ - 'not_your_child', - 'human_action_required', - 'authorization_changed', - 'permission_requests_disabled', - 'closed', - 'unavailable', - 'invalid_answer', - 'unsupported', + AgentCheckpointWorkspaceWarning: { + oneOf: [ + { + description: + 'Display to users: pause editors, terminals and other writers for the entire\ncapture. Hash verification is not a point-in-time consistency guarantee.', + type: 'string', + const: 'external_writers_not_fenced', + }, ], }, }, @@ -20817,168 +23061,510 @@ export const contracts = { includeInternal: { type: 'boolean', }, - }, - additionalProperties: false, - required: [ - 'sessionId', - 'through', - 'query', - 'caseSensitive', - 'wholeWord', - 'includeInternal', - ], - $defs: { - HistoryRowPosition: { + }, + additionalProperties: false, + required: [ + 'sessionId', + 'through', + 'query', + 'caseSensitive', + 'wholeWord', + 'includeInternal', + ], + $defs: { + HistoryRowPosition: { + description: + 'Disposable presentation locator, never authority or a canonical execution fact.\nThe source is the original emitting fact; the index precedes UI filtering.', + type: 'object', + properties: { + sourceSequence: { + type: 'integer', + format: 'uint64', + minimum: 1, + maximum: 9007199254740991, + }, + itemIndex: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + }, + additionalProperties: false, + required: ['sourceSequence', 'itemIndex'], + }, + }, + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentHistorySearchPage', + oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'ready', + }, + through: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + matches: { + type: 'array', + items: { + $ref: '#/$defs/AgentHistoryMatch', + }, + maxItems: 16, + }, + nextAfter: { + description: + 'Continue even when this batch has no matches. Null is the end of the scan.', + anyOf: [ + { + $ref: '#/$defs/HistoryRowPosition', + }, + { + type: 'null', + }, + ], + }, + }, + additionalProperties: false, + required: ['kind', 'through', 'matches', 'nextAfter'], + }, + ], + $defs: { + AgentHistoryMatch: { + type: 'object', + properties: { + field: { + description: + 'Exact canonical field to read through the source query; history body may omit it.', + $ref: '#/$defs/SourceField', + }, + position: { + description: + 'Exact transcript row. Open with HistoryPosition::Row at the same raw-event\nfence: Thinking for a thinking match, Body for every other source field.', + $ref: '#/$defs/HistoryRowPosition', + }, + preview: { + type: 'string', + maxLength: 384, + }, + }, + additionalProperties: false, + required: ['field', 'position', 'preview'], + }, + SourceField: { + description: + 'An explicitly selected canonical transcript field, never an execution grant.', + oneOf: [ + { + type: 'string', + enum: [ + 'body', + 'thinking', + 'tool_name', + 'failure_message', + 'error_class', + 'arguments', + 'result', + ], + }, + { + description: + 'The original service operation observation, separate from its returned value.', + type: 'string', + const: 'receipt', + }, + ], + }, + HistoryRowPosition: { + description: + 'Disposable presentation locator, never authority or a canonical execution fact.\nThe source is the original emitting fact; the index precedes UI filtering.', + type: 'object', + properties: { + sourceSequence: { + type: 'integer', + format: 'uint64', + minimum: 1, + maximum: 9007199254740991, + }, + itemIndex: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, + }, + }, + additionalProperties: false, + required: ['sourceSequence', 'itemIndex'], + }, + }, + }, + resources: {}, + }, + selectBackend: { + mode: 'mutation', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentSelectBackend', + type: 'object', + properties: { + sessionId: { + type: 'string', + }, + backend: { + $ref: '#/$defs/AgentBackendPreference', + }, + }, + additionalProperties: false, + required: ['sessionId', 'backend'], + $defs: { + AgentBackendPreference: { + description: + "The next turn's choice, independent of an already admitted execution.\nModel revisions are resolved afresh; preferences never grant execution authority.", + oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'model', + }, + connectionId: { + type: 'string', + minLength: 1, + }, + modelId: { + type: 'string', + minLength: 1, + }, + thinkingLevel: { + anyOf: [ + { + $ref: '#/$defs/ThinkingLevel', + }, + { + type: 'null', + }, + ], + }, + }, + additionalProperties: false, + required: ['kind', 'connectionId', 'modelId'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'executor', + }, + selection: { + $ref: '#/$defs/AgentExecutorSelection', + }, + }, + additionalProperties: false, + required: ['kind', 'selection'], + }, + ], + }, + ThinkingLevel: { + type: 'string', + enum: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + }, + AgentExecutorSelection: { + type: 'object', + properties: { + executor: { + type: 'string', + minLength: 1, + }, + settings: { + $ref: '#/$defs/ExecutorSettings', + }, + }, + additionalProperties: false, + required: ['executor', 'settings'], + }, + ExecutorSettings: { description: - 'Disposable presentation locator, never authority or a canonical execution fact.\nThe source is the original emitting fact; the index precedes UI filtering.', + 'Executor-owned model selection, independent of Host model connections.', type: 'object', properties: { - sourceSequence: { - type: 'integer', - format: 'uint64', - minimum: 1, - maximum: 9007199254740991, + model: { + type: ['string', 'null'], }, - itemIndex: { - type: 'integer', - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, + thinkingLevel: { + anyOf: [ + { + $ref: '#/$defs/ExecutorEffort', + }, + { + type: 'null', + }, + ], + }, + runtime: { + type: ['string', 'null'], }, }, additionalProperties: false, - required: ['sourceSequence', 'itemIndex'], + }, + ExecutorEffort: { + description: "Runtime-native effort, not a Host model's closed reasoning enum.", + type: 'string', }, }, }, output: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentHistorySearchPage', + title: 'AgentMetadataResult', oneOf: [ { type: 'object', properties: { kind: { type: 'string', - const: 'ready', + const: 'committed', }, - through: { + session: { + $ref: '#/$defs/AgentSession', + }, + }, + additionalProperties: false, + required: ['kind', 'session'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'revision_conflict', + }, + expected: { type: 'integer', format: 'uint64', minimum: 0, }, - matches: { - type: 'array', - items: { - $ref: '#/$defs/AgentHistoryMatch', - }, - maxItems: 16, + actual: { + type: 'integer', + format: 'uint64', + minimum: 0, }, - nextAfter: { + }, + additionalProperties: false, + required: ['kind', 'expected', 'actual'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'rejected', + }, + reason: { + $ref: '#/$defs/AgentRejection', + }, + }, + additionalProperties: false, + required: ['kind', 'reason'], + }, + ], + $defs: { + AgentSession: { + type: 'object', + properties: { + parentSessionId: { description: - 'Continue even when this batch has no matches. Null is the end of the scan.', + 'Session that initiated creation, independent of fork ancestry and task ownership.', + type: ['string', 'null'], + }, + branch: { anyOf: [ { - $ref: '#/$defs/HistoryRowPosition', + $ref: '#/$defs/AgentSessionBranch', + }, + { + type: 'null', + }, + ], + }, + backend: { + anyOf: [ + { + $ref: '#/$defs/AgentBackendPreference', }, { type: 'null', }, ], }, + tools: { + type: ['array', 'null'], + items: { + type: 'string', + }, + }, + dataSource: { + $ref: '#/$defs/AgentDataSource', + }, + permissions: { + $ref: '#/$defs/AgentWorkspacePermissions', + }, + boundaryRevision: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + binding: { + $ref: '#/$defs/BindingStamp', + }, + id: { + type: 'string', + }, + title: { + type: 'string', + }, + labels: { + type: 'array', + items: { + type: 'string', + }, + }, + workspace: { + $ref: '#/$defs/AgentWorkspace', + }, + revision: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + updatedAt: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + archived: { + type: 'boolean', + }, + pinned: { + type: 'boolean', + }, + hasUnread: { + type: 'boolean', + }, + tailMessageId: { + description: + 'Exact durable visible tail accepted by markRead; independent of preview clocks.', + type: ['string', 'null'], + }, + activity: { + $ref: '#/$defs/AgentSessionActivity', + }, }, additionalProperties: false, - required: ['kind', 'through', 'matches', 'nextAfter'], + required: [ + 'parentSessionId', + 'branch', + 'dataSource', + 'permissions', + 'boundaryRevision', + 'binding', + 'id', + 'title', + 'labels', + 'workspace', + 'revision', + 'updatedAt', + 'archived', + 'pinned', + 'hasUnread', + 'tailMessageId', + 'activity', + ], }, - ], - $defs: { - AgentHistoryMatch: { + AgentSessionBranch: { + description: + 'Frozen conversation ancestry for presentation, not authority over the source.', type: 'object', properties: { - field: { - description: - 'Exact canonical field to read through the source query; history body may omit it.', - $ref: '#/$defs/SourceField', + sessionId: { + type: 'string', + minLength: 1, }, position: { - description: - 'Exact transcript row. Open with HistoryPosition::Row at the same raw-event\nfence: Thinking for a thinking match, Body for every other source field.', - $ref: '#/$defs/HistoryRowPosition', - }, - preview: { - type: 'string', - maxLength: 384, + $ref: '#/$defs/AgentForkPosition', }, }, additionalProperties: false, - required: ['field', 'position', 'preview'], + required: ['sessionId', 'position'], }, - SourceField: { - description: - 'An explicitly selected canonical transcript field, never an execution grant.', + AgentForkPosition: { + description: 'A complete logical Turn boundary within the source session.', oneOf: [ { - type: 'string', - enum: [ - 'body', - 'thinking', - 'tool_name', - 'failure_message', - 'error_class', - 'arguments', - 'result', - ], + description: 'Capture an empty history prefix.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'start', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + description: 'Capture all current history, including later compaction facts.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'end', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + description: 'Include the complete target Turn.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'through_turn', + }, + turnId: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'turnId'], }, { description: - 'The original service operation observation, separate from its returned value.', - type: 'string', - const: 'receipt', + 'Exclude the complete target Turn, including its submitted inputs.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'before_turn', + }, + turnId: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'turnId'], }, ], }, - HistoryRowPosition: { - description: - 'Disposable presentation locator, never authority or a canonical execution fact.\nThe source is the original emitting fact; the index precedes UI filtering.', - type: 'object', - properties: { - sourceSequence: { - type: 'integer', - format: 'uint64', - minimum: 1, - maximum: 9007199254740991, - }, - itemIndex: { - type: 'integer', - format: 'uint64', - minimum: 0, - maximum: 9007199254740991, - }, - }, - additionalProperties: false, - required: ['sourceSequence', 'itemIndex'], - }, - }, - }, - resources: {}, - }, - selectBackend: { - mode: 'mutation', - input: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentSelectBackend', - type: 'object', - properties: { - sessionId: { - type: 'string', - }, - backend: { - $ref: '#/$defs/AgentBackendPreference', - }, - }, - additionalProperties: false, - required: ['sessionId', 'backend'], - $defs: { AgentBackendPreference: { description: "The next turn's choice, independent of an already admitted execution.\nModel revisions are resolved afresh; preferences never grant execution authority.", @@ -21074,250 +23660,322 @@ export const contracts = { description: "Runtime-native effort, not a Host model's closed reasoning enum.", type: 'string', }, + AgentDataSource: { + description: + 'Stable business-data origin, independent of package or activation revisions.', + type: 'object', + properties: { + stateDomain: { + type: 'string', + }, + deployment: { + type: 'string', + format: 'uuid', + }, + namespace: { + $ref: '#/$defs/InstanceId', + }, + }, + additionalProperties: false, + required: ['stateDomain', 'deployment', 'namespace'], + }, + InstanceId: { + description: + 'Hierarchical, stable source keys. Display aliases never become data identities.', + type: 'array', + items: { + type: 'string', + format: 'uuid', + }, + }, + AgentWorkspacePermissions: { + type: 'object', + properties: { + access: { + $ref: '#/$defs/AgentWorkspaceAccess', + }, + requestPermissions: { + type: 'boolean', + }, + }, + additionalProperties: false, + required: ['access', 'requestPermissions'], + }, + AgentWorkspaceAccess: { + description: + 'Session sandbox policy. Full access disables isolation and permits Computer Use\nwithout additional application, browser or audio consent.', + type: 'string', + enum: ['read_only', 'workspace_write', 'danger_full_access'], + }, + BindingStamp: { + type: 'object', + properties: { + id: { + type: 'string', + format: 'uuid', + }, + permissionEpoch: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['id', 'permissionEpoch'], + }, + AgentWorkspace: { + type: 'object', + properties: { + worktree: { + description: + 'Native owner allocation; never accepted from a caller as workspace authority.', + anyOf: [ + { + $ref: '#/$defs/Binding', + }, + { + type: 'null', + }, + ], + }, + projectId: { + description: "Optional membership in this Agent's canonical Project catalog.", + type: ['string', 'null'], + format: 'uuid', + }, + path: { + type: 'string', + minLength: 1, + }, + directoryIdentity: { + type: 'string', + pattern: '^sha256:[a-f0-9]{64}$', + }, + identity: { + type: 'string', + pattern: '^workspace:v1:[A-Fa-f0-9-]{36}$', + }, + }, + additionalProperties: false, + required: ['path', 'directoryIdentity', 'identity'], + }, + Binding: { + description: 'Immutable allocation intent, not the current child branch or HEAD.', + type: 'object', + properties: { + id: { + type: 'string', + }, + common_dir: { + type: 'string', + }, + source: { + type: 'string', + }, + source_identity: { + type: 'string', + pattern: '^sha256:[a-f0-9]{64}$', + }, + base_commit: { + type: 'string', + }, + directory: { + type: 'string', + }, + }, + additionalProperties: false, + required: [ + 'id', + 'common_dir', + 'source', + 'source_identity', + 'base_commit', + 'directory', + ], + }, + AgentSessionActivity: { + type: 'string', + enum: ['idle', 'working', 'waiting'], + }, + AgentRejection: { + type: 'string', + enum: [ + 'permission_change_requires_user', + 'title_invalid', + 'labels_invalid', + 'tools_unavailable', + 'busy', + 'workspace_unavailable', + 'session_unavailable', + 'history_unavailable', + 'managed_session', + 'closed', + ], + }, + }, + }, + resources: {}, + }, + selectWorkspace: { + mode: 'mutation', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentDirectoryPath', + description: + 'A directory on the Host: an absolute path, `~` standing for the home\ndirectory, within the configured workspace locations.', + type: 'object', + properties: { + path: { + type: 'string', + minLength: 1, + }, }, + additionalProperties: false, + required: ['path'], }, output: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentMetadataResult', - oneOf: [ - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'committed', - }, - session: { - $ref: '#/$defs/AgentSession', - }, - }, - additionalProperties: false, - required: ['kind', 'session'], - }, + title: 'Nullable_AgentWorkspace', + anyOf: [ { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'revision_conflict', - }, - expected: { - type: 'integer', - format: 'uint64', - minimum: 0, - }, - actual: { - type: 'integer', - format: 'uint64', - minimum: 0, - }, - }, - additionalProperties: false, - required: ['kind', 'expected', 'actual'], + $ref: '#/$defs/AgentWorkspace', }, { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'rejected', - }, - reason: { - $ref: '#/$defs/AgentRejection', - }, - }, - additionalProperties: false, - required: ['kind', 'reason'], + type: 'null', }, ], $defs: { - AgentSession: { + AgentWorkspace: { type: 'object', properties: { - parentSessionId: { + worktree: { description: - 'Session that initiated creation, independent of fork ancestry and task ownership.', - type: ['string', 'null'], - }, - branch: { - anyOf: [ - { - $ref: '#/$defs/AgentSessionBranch', - }, - { - type: 'null', - }, - ], - }, - backend: { + 'Native owner allocation; never accepted from a caller as workspace authority.', anyOf: [ { - $ref: '#/$defs/AgentBackendPreference', + $ref: '#/$defs/Binding', }, { type: 'null', }, ], }, - tools: { - type: ['array', 'null'], - items: { - type: 'string', - }, - }, - dataSource: { - $ref: '#/$defs/AgentDataSource', + projectId: { + description: "Optional membership in this Agent's canonical Project catalog.", + type: ['string', 'null'], + format: 'uuid', }, - permissions: { - $ref: '#/$defs/AgentWorkspacePermissions', + path: { + type: 'string', + minLength: 1, }, - boundaryRevision: { - type: 'integer', - format: 'uint64', - minimum: 0, + directoryIdentity: { + type: 'string', + pattern: '^sha256:[a-f0-9]{64}$', }, - binding: { - $ref: '#/$defs/BindingStamp', + identity: { + type: 'string', + pattern: '^workspace:v1:[A-Fa-f0-9-]{36}$', }, + }, + additionalProperties: false, + required: ['path', 'directoryIdentity', 'identity'], + }, + Binding: { + description: 'Immutable allocation intent, not the current child branch or HEAD.', + type: 'object', + properties: { id: { type: 'string', }, - title: { + common_dir: { type: 'string', }, - labels: { - type: 'array', - items: { - type: 'string', - }, - }, - workspace: { - $ref: '#/$defs/AgentWorkspace', - }, - revision: { - type: 'integer', - format: 'uint64', - minimum: 0, - }, - updatedAt: { - type: 'integer', - format: 'uint64', - minimum: 0, - }, - archived: { - type: 'boolean', - }, - pinned: { - type: 'boolean', + source: { + type: 'string', }, - hasUnread: { - type: 'boolean', + source_identity: { + type: 'string', + pattern: '^sha256:[a-f0-9]{64}$', }, - tailMessageId: { - description: - 'Exact durable visible tail accepted by markRead; independent of preview clocks.', - type: ['string', 'null'], + base_commit: { + type: 'string', }, - activity: { - $ref: '#/$defs/AgentSessionActivity', + directory: { + type: 'string', }, }, additionalProperties: false, required: [ - 'parentSessionId', - 'branch', - 'dataSource', - 'permissions', - 'boundaryRevision', - 'binding', 'id', - 'title', - 'labels', - 'workspace', - 'revision', - 'updatedAt', - 'archived', - 'pinned', - 'hasUnread', - 'tailMessageId', - 'activity', + 'common_dir', + 'source', + 'source_identity', + 'base_commit', + 'directory', ], }, - AgentSessionBranch: { + }, + }, + resources: {}, + }, + send: { + mode: 'mutation', + input: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentSend', + type: 'object', + properties: { + delivery: { + $ref: '#/$defs/AgentSendDelivery', + }, + expectedInvocationId: { description: - 'Frozen conversation ancestry for presentation, not authority over the source.', - type: 'object', - properties: { - sessionId: { - type: 'string', - minLength: 1, - }, - position: { - $ref: '#/$defs/AgentForkPosition', - }, + 'An active send must not start new work after Stop fences its original Run.', + type: ['string', 'null'], + }, + attachments: { + description: + 'IDs of immutable user uploads in this Session; metadata is resolved by the owner.', + type: 'array', + items: { + type: 'string', }, - additionalProperties: false, - required: ['sessionId', 'position'], + default: [], }, - AgentForkPosition: { - description: 'A complete logical Turn boundary within the source session.', - oneOf: [ + backend: { + description: 'Omission uses the session’s saved next-turn preference.', + anyOf: [ { - description: 'Capture an empty history prefix.', - type: 'object', - properties: { - kind: { - type: 'string', - const: 'start', - }, - }, - required: ['kind'], - additionalProperties: false, + $ref: '#/$defs/AgentBackendPreference', }, { - description: 'Capture all current history, including later compaction facts.', - type: 'object', - properties: { - kind: { - type: 'string', - const: 'end', - }, - }, - required: ['kind'], - additionalProperties: false, + type: 'null', }, + ], + }, + sessionId: { + type: 'string', + minLength: 1, + }, + message: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['sessionId', 'message'], + $defs: { + AgentSendDelivery: { + oneOf: [ { - description: 'Include the complete target Turn.', - type: 'object', - properties: { - kind: { - type: 'string', - const: 'through_turn', - }, - turnId: { - type: 'string', - minLength: 1, - }, - }, - additionalProperties: false, - required: ['kind', 'turnId'], + type: 'string', + enum: ['queue'], }, { description: - 'Exclude the complete target Turn, including its submitted inputs.', - type: 'object', - properties: { - kind: { - type: 'string', - const: 'before_turn', - }, - turnId: { - type: 'string', - minLength: 1, - }, - }, - additionalProperties: false, - required: ['kind', 'turnId'], + 'Steer an active model, Codex or Claude turn; agy interrupts and resumes where supported. Other executors queue.', + type: 'string', + const: 'steer', }, ], }, @@ -21416,324 +24074,437 @@ export const contracts = { description: "Runtime-native effort, not a Host model's closed reasoning enum.", type: 'string', }, - AgentDataSource: { - description: - 'Stable business-data origin, independent of package or activation revisions.', + }, + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentSendResult', + oneOf: [ + { type: 'object', properties: { - stateDomain: { + kind: { type: 'string', + const: 'rejected', }, - deployment: { + sessionId: { type: 'string', - format: 'uuid', }, - namespace: { - $ref: '#/$defs/InstanceId', + reason: { + $ref: '#/$defs/AgentSendRejection', + }, + skills: { + anyOf: [ + { + $ref: '#/$defs/SkillInvocationResult', + }, + { + type: 'null', + }, + ], }, }, additionalProperties: false, - required: ['stateDomain', 'deployment', 'namespace'], - }, - InstanceId: { - description: - 'Hierarchical, stable source keys. Display aliases never become data identities.', - type: 'array', - items: { - type: 'string', - format: 'uuid', - }, + required: ['kind', 'sessionId', 'reason'], }, - AgentWorkspacePermissions: { + { type: 'object', properties: { - access: { - $ref: '#/$defs/AgentWorkspaceAccess', + kind: { + type: 'string', + const: 'started', }, - requestPermissions: { - type: 'boolean', + sessionId: { + type: 'string', + }, + invocationId: { + type: 'string', + }, + messageId: { + type: 'string', }, }, additionalProperties: false, - required: ['access', 'requestPermissions'], - }, - AgentWorkspaceAccess: { - description: - 'Session sandbox policy. Full access disables isolation and permits Computer Use\nwithout additional application, browser or audio consent.', - type: 'string', - enum: ['read_only', 'workspace_write', 'danger_full_access'], + required: ['kind', 'sessionId', 'invocationId', 'messageId'], }, - BindingStamp: { + { type: 'object', properties: { - id: { + kind: { type: 'string', - format: 'uuid', + const: 'queued', }, - permissionEpoch: { + sessionId: { + type: 'string', + }, + messageId: { + type: 'string', + }, + revision: { type: 'integer', format: 'uint64', minimum: 0, }, }, additionalProperties: false, - required: ['id', 'permissionEpoch'], + required: ['kind', 'sessionId', 'messageId', 'revision'], }, - AgentWorkspace: { + { + description: + 'The caller may not send to this conversation: the Bot sends only to the\nworkers it created. Nothing was admitted.', type: 'object', properties: { - worktree: { - description: - 'Native owner allocation; never accepted from a caller as workspace authority.', - anyOf: [ - { - $ref: '#/$defs/Binding', - }, - { - type: 'null', - }, - ], - }, - projectId: { - description: "Optional membership in this Agent's canonical Project catalog.", - type: ['string', 'null'], - format: 'uuid', - }, - path: { + kind: { type: 'string', - minLength: 1, + const: 'not_your_worker', }, - directoryIdentity: { + sessionId: { type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', }, - identity: { - type: 'string', - pattern: '^workspace:v1:[A-Fa-f0-9-]{36}$', + }, + additionalProperties: false, + required: ['kind', 'sessionId'], + }, + ], + $defs: { + AgentSendRejection: { + type: 'string', + enum: ['model_unavailable', 'skills_unavailable', 'turn_stopped'], + }, + SkillInvocationResult: { + type: 'object', + properties: { + loaded: { + type: 'array', + items: { + $ref: '#/$defs/LoadedSkill', + }, + }, + failed: { + type: 'array', + items: { + $ref: '#/$defs/SkillInvocationFailure', + }, + }, + receipts: { + type: 'array', + items: { + $ref: '#/$defs/SkillInvocationReceipt', + }, }, }, additionalProperties: false, - required: ['path', 'directoryIdentity', 'identity'], + required: ['loaded', 'failed', 'receipts'], }, - Binding: { - description: 'Immutable allocation intent, not the current child branch or HEAD.', + LoadedSkill: { type: 'object', properties: { id: { type: 'string', }, - common_dir: { + name: { type: 'string', }, - source: { - type: 'string', + }, + additionalProperties: false, + required: ['id', 'name'], + }, + SkillInvocationFailure: { + anyOf: [ + { + $ref: '#/$defs/SkillRequestFailure', }, - source_identity: { + ], + }, + SkillRequestFailure: { + type: 'object', + properties: { + request: { type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', }, - base_commit: { - type: 'string', + reason: { + $ref: '#/$defs/SkillFailureReason', + }, + }, + additionalProperties: false, + required: ['request', 'reason'], + }, + SkillFailureReason: { + type: 'string', + enum: [ + 'invalid_name', + 'not_found', + 'disabled', + 'host_incompatible', + 'resolution_failed', + ], + }, + SkillInvocationReceipt: { + description: + 'Boolean wire discriminants are validated here, not retained as mutable\nbusiness fields beside a contradictory enum variant.', + anyOf: [ + { + type: 'object', + properties: { + invocation: { + $ref: '#/$defs/SkillInvocationMode', + }, + request: { + type: 'string', + }, + success: { + const: true, + }, + ref: { + type: 'string', + }, + id: { + type: 'string', + }, + name: { + type: 'string', + }, + scope: { + $ref: '#/$defs/SkillScope', + }, + source: { + $ref: '#/$defs/SkillSource', + }, + }, + additionalProperties: false, + required: [ + 'invocation', + 'request', + 'success', + 'ref', + 'id', + 'name', + 'scope', + 'source', + ], }, - directory: { - type: 'string', + { + type: 'object', + properties: { + invocation: { + $ref: '#/$defs/SkillInvocationMode', + }, + request: { + type: 'string', + }, + success: { + const: false, + }, + reason: { + $ref: '#/$defs/SkillFailureReason', + }, + }, + additionalProperties: false, + required: ['invocation', 'request', 'success', 'reason'], }, - }, - additionalProperties: false, - required: [ - 'id', - 'common_dir', - 'source', - 'source_identity', - 'base_commit', - 'directory', ], }, - AgentSessionActivity: { + SkillInvocationMode: { type: 'string', - enum: ['idle', 'working', 'waiting'], + enum: ['explicit', 'model_tool'], }, - AgentRejection: { + SkillScope: { type: 'string', - enum: [ - 'permission_change_requires_user', - 'title_invalid', - 'labels_invalid', - 'tools_unavailable', - 'busy', - 'workspace_unavailable', - 'session_unavailable', - 'history_unavailable', - 'managed_session', - 'closed', - ], + enum: ['project', 'workspace', 'user', 'custom'], + }, + SkillSource: { + type: 'string', + enum: ['maka', 'agents', 'custom'], }, }, }, resources: {}, }, - selectWorkspace: { - mode: 'mutation', + sessionCheckpointRestoreReceipt: { + mode: 'query', input: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentDirectoryPath', - description: - 'A directory on the Host: an absolute path, `~` standing for the home\ndirectory, within the configured workspace locations.', + title: 'AgentSessionCheckpointRestoreReceiptRead', type: 'object', properties: { - path: { - type: 'string', - minLength: 1, + reference: { + $ref: '#/$defs/RecoveryRef', + }, + input: { + $ref: '#/$defs/AgentSessionCheckpointRestore', }, }, additionalProperties: false, - required: ['path'], - }, - output: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'Nullable_AgentWorkspace', - anyOf: [ - { - $ref: '#/$defs/AgentWorkspace', - }, - { - type: 'null', - }, - ], + required: ['reference', 'input'], $defs: { - AgentWorkspace: { + RecoveryRef: { type: 'object', properties: { - worktree: { - description: - 'Native owner allocation; never accepted from a caller as workspace authority.', - anyOf: [ - { - $ref: '#/$defs/Binding', - }, - { - type: 'null', - }, - ], + source: { + $ref: '#/$defs/DeploymentRef', }, - projectId: { - description: "Optional membership in this Agent's canonical Project catalog.", - type: ['string', 'null'], - format: 'uuid', + namespace: { + $ref: '#/$defs/InstanceId', }, - path: { + export: { type: 'string', - minLength: 1, }, - directoryIdentity: { + method: { type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', }, - identity: { + operation: { type: 'string', - pattern: '^workspace:v1:[A-Fa-f0-9-]{36}$', + format: 'uuid', + }, + fingerprint: { + type: 'string', + }, + contract: { + $ref: '#/$defs/RecoveryContract', + }, + recovery: { + $ref: '#/$defs/RecoveryProtocol', }, }, additionalProperties: false, - required: ['path', 'directoryIdentity', 'identity'], + required: [ + 'source', + 'namespace', + 'export', + 'method', + 'operation', + 'fingerprint', + 'contract', + 'recovery', + ], }, - Binding: { - description: 'Immutable allocation intent, not the current child branch or HEAD.', + DeploymentRef: { type: 'object', properties: { - id: { - type: 'string', - }, - common_dir: { - type: 'string', - }, - source: { - type: 'string', - }, - source_identity: { + stateDomain: { type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', }, - base_commit: { + deployment: { type: 'string', + format: 'uuid', }, - directory: { - type: 'string', + revision: { + type: 'integer', + format: 'uint64', + minimum: 0, }, }, additionalProperties: false, - required: [ - 'id', - 'common_dir', - 'source', - 'source_identity', - 'base_commit', - 'directory', - ], - }, - }, - }, - resources: {}, - }, - send: { - mode: 'mutation', - input: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentSend', - type: 'object', - properties: { - delivery: { - $ref: '#/$defs/AgentSendDelivery', - }, - expectedInvocationId: { - description: - 'An active send must not start new work after Stop fences its original Run.', - type: ['string', 'null'], + required: ['stateDomain', 'deployment', 'revision'], }, - attachments: { + InstanceId: { description: - 'IDs of immutable user uploads in this Session; metadata is resolved by the owner.', + 'Hierarchical, stable source keys. Display aliases never become data identities.', type: 'array', items: { type: 'string', + format: 'uuid', }, - default: [], }, - backend: { - description: 'Omission uses the session’s saved next-turn preference.', - anyOf: [ - { - $ref: '#/$defs/AgentBackendPreference', + RecoveryContract: { + description: + 'Contract identity for a journal lookup. The original request retains the\ncomplete contract; a receipt carries only its digest.', + type: 'object', + properties: { + name: { + type: 'string', }, - { - type: 'null', + version: { + type: 'integer', + format: 'uint32', + minimum: 0, }, - ], + transport: { + $ref: '#/$defs/Transport', + }, + digest: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['name', 'version', 'transport', 'digest'], }, - sessionId: { + Transport: { type: 'string', - minLength: 1, + enum: ['local', 'remote'], }, - message: { + RecoveryProtocol: { type: 'string', + enum: ['journal_v1'], }, - }, - additionalProperties: false, - required: ['sessionId', 'message'], - $defs: { - AgentSendDelivery: { - oneOf: [ - { + AgentSessionCheckpointRestore: { + description: + 'Restore files into a new child of a selected directory and rebind the same,\nunchanged local Session. It never starts a model/tool call or rewinds history.', + type: 'object', + properties: { + checkpoint: { + $ref: '#/$defs/AgentHistoryCheckpointRead', + }, + parent: { + description: + 'Absolute parent path. The Host captures and validates its directory identity.', + type: 'string', + minLength: 1, + }, + permissions: { + $ref: '#/$defs/AgentWorkspacePermissions', + }, + backend: { + $ref: '#/$defs/AgentBackendPreference', + }, + acceptExternalWritersRisk: { + type: 'boolean', + }, + }, + additionalProperties: false, + required: [ + 'checkpoint', + 'parent', + 'permissions', + 'backend', + 'acceptExternalWritersRisk', + ], + }, + AgentHistoryCheckpointRead: { + type: 'object', + properties: { + sessionId: { type: 'string', - enum: ['queue'], + minLength: 1, }, - { + revision: { description: - 'Steer an active model, Codex or Claude turn; agy interrupts and resumes where supported. Other executors queue.', + 'Only the exact currently retained revision is readable; no latest fallback.', type: 'string', - const: 'steer', + format: 'uuid', }, - ], + }, + additionalProperties: false, + required: ['sessionId', 'revision'], + }, + AgentWorkspacePermissions: { + type: 'object', + properties: { + access: { + $ref: '#/$defs/AgentWorkspaceAccess', + }, + requestPermissions: { + type: 'boolean', + }, + }, + additionalProperties: false, + required: ['access', 'requestPermissions'], + }, + AgentWorkspaceAccess: { + description: + 'Session sandbox policy. Full access disables isolation and permits Computer Use\nwithout additional application, browser or audio consent.', + type: 'string', + enum: ['read_only', 'workspace_write', 'danger_full_access'], }, AgentBackendPreference: { description: @@ -21818,260 +24589,359 @@ export const contracts = { { type: 'null', }, - ], + ], + }, + runtime: { + type: ['string', 'null'], + }, + }, + additionalProperties: false, + }, + ExecutorEffort: { + description: "Runtime-native effort, not a Host model's closed reasoning enum.", + type: 'string', + }, + }, + }, + output: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AgentSessionCheckpointRestoreReceipt', + type: 'object', + properties: { + operation: { + anyOf: [ + { + $ref: '#/$defs/OperationObservation', + }, + { + type: 'null', + }, + ], + }, + restore: { + anyOf: [ + { + $ref: '#/$defs/AgentSessionCheckpointRestored', + }, + { + type: 'null', + }, + ], + }, + }, + additionalProperties: false, + $defs: { + OperationObservation: { + type: 'object', + properties: { + reference: { + $ref: '#/$defs/RecoveryRef', + }, + outcome: { + $ref: '#/$defs/OperationOutcome', + }, + cleanup: { + $ref: '#/$defs/OperationCleanup', + }, + }, + additionalProperties: false, + required: ['reference', 'outcome', 'cleanup'], + }, + RecoveryRef: { + type: 'object', + properties: { + source: { + $ref: '#/$defs/DeploymentRef', + }, + namespace: { + $ref: '#/$defs/InstanceId', + }, + export: { + type: 'string', + }, + method: { + type: 'string', + }, + operation: { + type: 'string', + format: 'uuid', + }, + fingerprint: { + type: 'string', + }, + contract: { + $ref: '#/$defs/RecoveryContract', + }, + recovery: { + $ref: '#/$defs/RecoveryProtocol', + }, + }, + additionalProperties: false, + required: [ + 'source', + 'namespace', + 'export', + 'method', + 'operation', + 'fingerprint', + 'contract', + 'recovery', + ], + }, + DeploymentRef: { + type: 'object', + properties: { + stateDomain: { + type: 'string', + }, + deployment: { + type: 'string', + format: 'uuid', + }, + revision: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['stateDomain', 'deployment', 'revision'], + }, + InstanceId: { + description: + 'Hierarchical, stable source keys. Display aliases never become data identities.', + type: 'array', + items: { + type: 'string', + format: 'uuid', + }, + }, + RecoveryContract: { + description: + 'Contract identity for a journal lookup. The original request retains the\ncomplete contract; a receipt carries only its digest.', + type: 'object', + properties: { + name: { + type: 'string', + }, + version: { + type: 'integer', + format: 'uint32', + minimum: 0, + }, + transport: { + $ref: '#/$defs/Transport', + }, + digest: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['name', 'version', 'transport', 'digest'], + }, + Transport: { + type: 'string', + enum: ['local', 'remote'], + }, + RecoveryProtocol: { + type: 'string', + enum: ['journal_v1'], + }, + OperationOutcome: { + description: + 'Unresolved means no durable result is known, including after process loss.', + oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'unresolved', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'returned', + }, + value: true, + }, + additionalProperties: false, + required: ['kind', 'value'], + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'unknown', + }, + diagnostic: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['kind', 'diagnostic'], + }, + ], + }, + OperationCleanup: { + oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'unconfirmed', + }, + }, + required: ['kind'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'confirmed', + }, + }, + required: ['kind'], + additionalProperties: false, }, - runtime: { - type: ['string', 'null'], + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'failed', + }, + diagnostic: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['kind', 'diagnostic'], }, - }, - additionalProperties: false, - }, - ExecutorEffort: { - description: "Runtime-native effort, not a Host model's closed reasoning enum.", - type: 'string', + ], }, - }, - }, - output: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentSendResult', - oneOf: [ - { + AgentSessionCheckpointRestored: { type: 'object', properties: { - kind: { + operation: { type: 'string', - const: 'rejected', + format: 'uuid', }, sessionId: { type: 'string', }, - reason: { - $ref: '#/$defs/AgentSendRejection', + sessionRevision: { + type: 'integer', + format: 'uint64', + minimum: 0, + maximum: 9007199254740991, }, - skills: { - anyOf: [ - { - $ref: '#/$defs/SkillInvocationResult', - }, - { - type: 'null', - }, - ], + head: { + $ref: '#/$defs/AgentHistoryCheckpointHead', + }, + workspace: { + $ref: '#/$defs/AgentCheckpointWorkspaceSnapshot', + }, + path: { + type: 'string', }, }, additionalProperties: false, - required: ['kind', 'sessionId', 'reason'], + required: [ + 'operation', + 'sessionId', + 'sessionRevision', + 'head', + 'workspace', + 'path', + ], }, - { + AgentHistoryCheckpointHead: { type: 'object', properties: { - kind: { - type: 'string', - const: 'started', - }, - sessionId: { - type: 'string', - }, - invocationId: { + revision: { type: 'string', + format: 'uuid', }, - messageId: { - type: 'string', + manifest: { + $ref: '#/$defs/AgentHistoryCheckpointObject', }, }, additionalProperties: false, - required: ['kind', 'sessionId', 'invocationId', 'messageId'], + required: ['revision', 'manifest'], }, - { + AgentHistoryCheckpointObject: { type: 'object', properties: { - kind: { - type: 'string', - const: 'queued', - }, - sessionId: { - type: 'string', - }, - messageId: { + digest: { type: 'string', + pattern: '^[a-f0-9]{64}$', }, - revision: { + bytes: { type: 'integer', format: 'uint64', minimum: 0, + maximum: 9007199254740991, }, }, additionalProperties: false, - required: ['kind', 'sessionId', 'messageId', 'revision'], + required: ['digest', 'bytes'], }, - { - description: - 'The caller may not send to this conversation: the Bot sends only to the\nworkers it created. Nothing was admitted.', + AgentCheckpointWorkspaceSnapshot: { type: 'object', properties: { - kind: { - type: 'string', - const: 'not_your_worker', - }, - sessionId: { + identity: { type: 'string', }, - }, - additionalProperties: false, - required: ['kind', 'sessionId'], - }, - ], - $defs: { - AgentSendRejection: { - type: 'string', - enum: ['model_unavailable', 'skills_unavailable', 'turn_stopped'], - }, - SkillInvocationResult: { - type: 'object', - properties: { - loaded: { - type: 'array', - items: { - $ref: '#/$defs/LoadedSkill', - }, + consistency: { + $ref: '#/$defs/AgentCheckpointWorkspaceConsistency', }, - failed: { - type: 'array', - items: { - $ref: '#/$defs/SkillInvocationFailure', - }, + warning: { + $ref: '#/$defs/AgentCheckpointWorkspaceWarning', }, - receipts: { - type: 'array', - items: { - $ref: '#/$defs/SkillInvocationReceipt', - }, + inventory: { + $ref: '#/$defs/AgentHistoryCheckpointObject', }, }, additionalProperties: false, - required: ['loaded', 'failed', 'receipts'], + required: ['identity', 'consistency', 'warning', 'inventory'], }, - LoadedSkill: { - type: 'object', - properties: { - id: { - type: 'string', - }, - name: { + AgentCheckpointWorkspaceConsistency: { + oneOf: [ + { + description: + 'The caller confirms all other writers remain paused for the entire copy.', type: 'string', + const: 'caller_confirmed_quiescent', }, - }, - additionalProperties: false, - required: ['id', 'name'], - }, - SkillInvocationFailure: { - anyOf: [ { - $ref: '#/$defs/SkillRequestFailure', - }, - ], - }, - SkillRequestFailure: { - type: 'object', - properties: { - request: { + description: + 'Files may not represent one instant; successful digest checks do not change this.', type: 'string', + const: 'unverified', }, - reason: { - $ref: '#/$defs/SkillFailureReason', - }, - }, - additionalProperties: false, - required: ['request', 'reason'], - }, - SkillFailureReason: { - type: 'string', - enum: [ - 'invalid_name', - 'not_found', - 'disabled', - 'host_incompatible', - 'resolution_failed', ], }, - SkillInvocationReceipt: { - description: - 'Boolean wire discriminants are validated here, not retained as mutable\nbusiness fields beside a contradictory enum variant.', - anyOf: [ - { - type: 'object', - properties: { - invocation: { - $ref: '#/$defs/SkillInvocationMode', - }, - request: { - type: 'string', - }, - success: { - const: true, - }, - ref: { - type: 'string', - }, - id: { - type: 'string', - }, - name: { - type: 'string', - }, - scope: { - $ref: '#/$defs/SkillScope', - }, - source: { - $ref: '#/$defs/SkillSource', - }, - }, - additionalProperties: false, - required: [ - 'invocation', - 'request', - 'success', - 'ref', - 'id', - 'name', - 'scope', - 'source', - ], - }, + AgentCheckpointWorkspaceWarning: { + oneOf: [ { - type: 'object', - properties: { - invocation: { - $ref: '#/$defs/SkillInvocationMode', - }, - request: { - type: 'string', - }, - success: { - const: false, - }, - reason: { - $ref: '#/$defs/SkillFailureReason', - }, - }, - additionalProperties: false, - required: ['invocation', 'request', 'success', 'reason'], + description: + 'Display to users: pause editors, terminals and other writers for the entire\ncapture. Hash verification is not a point-in-time consistency guarantee.', + type: 'string', + const: 'external_writers_not_fenced', }, ], }, - SkillInvocationMode: { - type: 'string', - enum: ['explicit', 'model_tool'], - }, - SkillScope: { - type: 'string', - enum: ['project', 'workspace', 'user', 'custom'], - }, - SkillSource: { - type: 'string', - enum: ['maka', 'agents', 'custom'], - }, }, }, resources: {}, diff --git a/packages/plugin-sdk/src/generated/methods.ts b/packages/plugin-sdk/src/generated/methods.ts index e0dcc03a202..0e4d705aee3 100644 --- a/packages/plugin-sdk/src/generated/methods.ts +++ b/packages/plugin-sdk/src/generated/methods.ts @@ -20,6 +20,73 @@ // Generated from Rust runtime schemas. Run npm run schema:generate -w @maka-agent/plugin-sdk. import type * as Runtime from './runtime.js'; +/** + * Hierarchical, stable source keys. Display aliases never become data identities. + */ +export type AgentCheckpointReceiptInput_InstanceId = string[]; +export type AgentCheckpointReceiptInput_Transport = 'local' | 'remote'; +export type AgentCheckpointReceiptInput_RecoveryProtocol = 'journal_v1'; +export type AgentCheckpointReceiptInput_AgentCheckpointWorkspaceConsistency = + | 'caller_confirmed_quiescent' + | 'unverified'; +/** + * Hierarchical, stable source keys. Display aliases never become data identities. + */ +export type AgentCheckpointReceiptOutput_InstanceId = string[]; +export type AgentCheckpointReceiptOutput_Transport = 'local' | 'remote'; +export type AgentCheckpointReceiptOutput_RecoveryProtocol = 'journal_v1'; +/** + * Unresolved means no durable result is known, including after process loss. + */ +export type AgentCheckpointReceiptOutput_OperationOutcome = + | { + kind: 'unresolved'; + } + | { + kind: 'returned'; + value: unknown; + } + | { + kind: 'unknown'; + diagnostic: string; + }; +export type AgentCheckpointReceiptOutput_OperationCleanup = + | { + kind: 'unconfirmed'; + } + | { + kind: 'confirmed'; + } + | { + kind: 'failed'; + diagnostic: string; + }; +export type AgentCheckpointReceiptOutput_AgentHistoryCheckpointState = + | { + kind: 'capturing'; + } + | { + kind: 'prepared'; + } + | { + kind: 'published'; + head: AgentCheckpointReceiptOutput_AgentHistoryCheckpointHead; + } + | { + kind: 'conflict'; + actual?: string | null; + } + | { + kind: 'interrupted'; + } + | { + kind: 'unavailable'; + }; +export type AgentCheckpointReceiptOutput_AgentCheckpointWorkspaceConsistency = + | 'caller_confirmed_quiescent' + | 'unverified'; +export type AgentCheckpointReceiptOutput_AgentCheckpointWorkspaceWarning = + 'external_writers_not_fenced'; export type AgentCompactOutput_AgentExecutionState = | 'admitted' | 'running' @@ -37,19 +104,19 @@ export type AgentCompactOutput_AgentCompactRejection = /** * Hierarchical, stable source keys. Display aliases never become data identities. */ -export type AgentHistoryCheckpointReceiptInput_InstanceId = string[]; -export type AgentHistoryCheckpointReceiptInput_Transport = 'local' | 'remote'; -export type AgentHistoryCheckpointReceiptInput_RecoveryProtocol = 'journal_v1'; +export type AgentHistoryCheckpointLoadReceiptInput_InstanceId = string[]; +export type AgentHistoryCheckpointLoadReceiptInput_Transport = 'local' | 'remote'; +export type AgentHistoryCheckpointLoadReceiptInput_RecoveryProtocol = 'journal_v1'; /** * Hierarchical, stable source keys. Display aliases never become data identities. */ -export type AgentHistoryCheckpointReceiptOutput_InstanceId = string[]; -export type AgentHistoryCheckpointReceiptOutput_Transport = 'local' | 'remote'; -export type AgentHistoryCheckpointReceiptOutput_RecoveryProtocol = 'journal_v1'; +export type AgentHistoryCheckpointLoadReceiptOutput_InstanceId = string[]; +export type AgentHistoryCheckpointLoadReceiptOutput_Transport = 'local' | 'remote'; +export type AgentHistoryCheckpointLoadReceiptOutput_RecoveryProtocol = 'journal_v1'; /** * Unresolved means no durable result is known, including after process loss. */ -export type AgentHistoryCheckpointReceiptOutput_OperationOutcome = +export type AgentHistoryCheckpointLoadReceiptOutput_OperationOutcome = | { kind: 'unresolved'; } @@ -61,7 +128,7 @@ export type AgentHistoryCheckpointReceiptOutput_OperationOutcome = kind: 'unknown'; diagnostic: string; }; -export type AgentHistoryCheckpointReceiptOutput_OperationCleanup = +export type AgentHistoryCheckpointLoadReceiptOutput_OperationCleanup = | { kind: 'unconfirmed'; } @@ -72,33 +139,82 @@ export type AgentHistoryCheckpointReceiptOutput_OperationCleanup = kind: 'failed'; diagnostic: string; }; -export type AgentHistoryCheckpointReceiptOutput_AgentHistoryCheckpointState = +export type AgentHistoryCheckpointLoadReceiptOutput_AgentCheckpointLoadState = | { - kind: 'capturing'; + kind: 'preparing'; } | { - kind: 'prepared'; + kind: 'ready'; + sessionIds: string[]; + workspace?: AgentHistoryCheckpointLoadReceiptOutput_AgentCheckpointWorkspaceSnapshot | null; } | { - kind: 'published'; - head: AgentHistoryCheckpointReceiptOutput_AgentHistoryCheckpointHead; - } + kind: 'unavailable'; + }; +export type AgentHistoryCheckpointLoadReceiptOutput_AgentCheckpointWorkspaceConsistency = + | 'caller_confirmed_quiescent' + | 'unverified'; +export type AgentHistoryCheckpointLoadReceiptOutput_AgentCheckpointWorkspaceWarning = + 'external_writers_not_fenced'; +export type AgentLoadHistoryCheckpointOutput_AgentCheckpointLoadState = | { - kind: 'conflict'; - actual?: string | null; + kind: 'preparing'; } | { - kind: 'interrupted'; + kind: 'ready'; + sessionIds: string[]; + workspace?: AgentLoadHistoryCheckpointOutput_AgentCheckpointWorkspaceSnapshot | null; } | { kind: 'unavailable'; }; +export type AgentLoadHistoryCheckpointOutput_AgentCheckpointWorkspaceConsistency = + | 'caller_confirmed_quiescent' + | 'unverified'; +export type AgentLoadHistoryCheckpointOutput_AgentCheckpointWorkspaceWarning = + 'external_writers_not_fenced'; +/** + * Row reveals an exact field; Before/After continue at byte boundaries and + * Event addresses the raw event log. Returned chunks retain ascending presentation + * order, including a reverse window. + */ +export type AgentLoadedHistoryCheckpointHistoryInput_HistoryPosition = + | { + kind: 'start'; + } + | { + kind: 'tail'; + } + | { + kind: 'row'; + position: AgentLoadedHistoryCheckpointHistoryInput_HistoryRowPosition; + field: AgentLoadedHistoryCheckpointHistoryInput_HistoryField; + } + | { + kind: 'before'; + cursor: AgentLoadedHistoryCheckpointHistoryInput_Cursor; + } + | { + kind: 'after'; + cursor: AgentLoadedHistoryCheckpointHistoryInput_Cursor; + } + | { + kind: 'event'; + sequence: number; + }; +/** + * A readable field in a conversation window, in presentation order. + */ +export type AgentLoadedHistoryCheckpointHistoryInput_HistoryField = 'thinking' | 'body'; export type AgentOpenBotOutput_AgentBotRejection = | 'disabled' | 'creation_rejected' | 'no_workspace'; export type AgentPredictNextMessageInput_AgentPredictionTrigger = 'live' | 'catch_up'; -export type AgentPublishHistoryCheckpointOutput_AgentHistoryCheckpointState = +export type AgentPublishCheckpointInput_AgentCheckpointWorkspaceConsistency = + | 'caller_confirmed_quiescent' + | 'unverified'; +export type AgentPublishCheckpointOutput_AgentHistoryCheckpointState = | { kind: 'capturing'; } @@ -107,7 +223,7 @@ export type AgentPublishHistoryCheckpointOutput_AgentHistoryCheckpointState = } | { kind: 'published'; - head: AgentPublishHistoryCheckpointOutput_AgentHistoryCheckpointHead; + head: AgentPublishCheckpointOutput_AgentHistoryCheckpointHead; } | { kind: 'conflict'; @@ -119,6 +235,11 @@ export type AgentPublishHistoryCheckpointOutput_AgentHistoryCheckpointState = | { kind: 'unavailable'; }; +export type AgentPublishCheckpointOutput_AgentCheckpointWorkspaceConsistency = + | 'caller_confirmed_quiescent' + | 'unverified'; +export type AgentPublishCheckpointOutput_AgentCheckpointWorkspaceWarning = + 'external_writers_not_fenced'; /** * All parts are subscription items; only End admits a complete preview. * Offsets and byte counts refer to original bytes, not Base64 or UTF-16 lengths. @@ -167,10 +288,11 @@ export type AgentReadFileOutput_AgentFileContent = export type AgentReadFileOutput_AgentFileKind = 'file' | 'directory' | 'symlink' | 'other'; export type AgentReadHistoryCheckpointOutput_AgentHistoryCheckpointFrame = | { - kind: 'native_history_v1'; + kind: 'checkpoint'; head: AgentReadHistoryCheckpointOutput_AgentHistoryCheckpointHead; inventory: AgentReadHistoryCheckpointOutput_AgentHistoryCheckpointInventory; bundle: AgentReadHistoryCheckpointOutput_AgentHistoryCheckpointObject; + workspace?: AgentReadHistoryCheckpointOutput_AgentCheckpointWorkspaceSnapshot | null; } | { kind: 'chunk'; @@ -182,6 +304,131 @@ export type AgentReadHistoryCheckpointOutput_AgentHistoryCheckpointFrame = digest: string; bytes: number; }; +export type AgentReadHistoryCheckpointOutput_AgentCheckpointWorkspaceConsistency = + | 'caller_confirmed_quiescent' + | 'unverified'; +export type AgentReadHistoryCheckpointOutput_AgentCheckpointWorkspaceWarning = + 'external_writers_not_fenced'; +/** + * Session sandbox policy. Full access disables isolation and permits Computer Use + * without additional application, browser or audio consent. + */ +export type AgentRestoreSessionCheckpointInput_AgentWorkspaceAccess = + | 'read_only' + | 'workspace_write' + | 'danger_full_access'; +/** + * The next turn's choice, independent of an already admitted execution. + * Model revisions are resolved afresh; preferences never grant execution authority. + */ +export type AgentRestoreSessionCheckpointInput_AgentBackendPreference = + | { + kind: 'model'; + connectionId: string; + modelId: string; + thinkingLevel?: AgentRestoreSessionCheckpointInput_ThinkingLevel | null; + } + | { + kind: 'executor'; + selection: AgentRestoreSessionCheckpointInput_AgentExecutorSelection; + }; +export type AgentRestoreSessionCheckpointInput_ThinkingLevel = + | 'off' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'max' + | 'ultra'; +/** + * Runtime-native effort, not a Host model's closed reasoning enum. + */ +export type AgentRestoreSessionCheckpointInput_ExecutorEffort = string; +export type AgentRestoreSessionCheckpointOutput_AgentCheckpointWorkspaceConsistency = + | 'caller_confirmed_quiescent' + | 'unverified'; +export type AgentRestoreSessionCheckpointOutput_AgentCheckpointWorkspaceWarning = + 'external_writers_not_fenced'; +/** + * Hierarchical, stable source keys. Display aliases never become data identities. + */ +export type AgentSessionCheckpointRestoreReceiptInput_InstanceId = string[]; +export type AgentSessionCheckpointRestoreReceiptInput_Transport = 'local' | 'remote'; +export type AgentSessionCheckpointRestoreReceiptInput_RecoveryProtocol = 'journal_v1'; +/** + * Session sandbox policy. Full access disables isolation and permits Computer Use + * without additional application, browser or audio consent. + */ +export type AgentSessionCheckpointRestoreReceiptInput_AgentWorkspaceAccess = + | 'read_only' + | 'workspace_write' + | 'danger_full_access'; +/** + * The next turn's choice, independent of an already admitted execution. + * Model revisions are resolved afresh; preferences never grant execution authority. + */ +export type AgentSessionCheckpointRestoreReceiptInput_AgentBackendPreference = + | { + kind: 'model'; + connectionId: string; + modelId: string; + thinkingLevel?: AgentSessionCheckpointRestoreReceiptInput_ThinkingLevel | null; + } + | { + kind: 'executor'; + selection: AgentSessionCheckpointRestoreReceiptInput_AgentExecutorSelection; + }; +export type AgentSessionCheckpointRestoreReceiptInput_ThinkingLevel = + | 'off' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'max' + | 'ultra'; +/** + * Runtime-native effort, not a Host model's closed reasoning enum. + */ +export type AgentSessionCheckpointRestoreReceiptInput_ExecutorEffort = string; +/** + * Hierarchical, stable source keys. Display aliases never become data identities. + */ +export type AgentSessionCheckpointRestoreReceiptOutput_InstanceId = string[]; +export type AgentSessionCheckpointRestoreReceiptOutput_Transport = 'local' | 'remote'; +export type AgentSessionCheckpointRestoreReceiptOutput_RecoveryProtocol = 'journal_v1'; +/** + * Unresolved means no durable result is known, including after process loss. + */ +export type AgentSessionCheckpointRestoreReceiptOutput_OperationOutcome = + | { + kind: 'unresolved'; + } + | { + kind: 'returned'; + value: unknown; + } + | { + kind: 'unknown'; + diagnostic: string; + }; +export type AgentSessionCheckpointRestoreReceiptOutput_OperationCleanup = + | { + kind: 'unconfirmed'; + } + | { + kind: 'confirmed'; + } + | { + kind: 'failed'; + diagnostic: string; + }; +export type AgentSessionCheckpointRestoreReceiptOutput_AgentCheckpointWorkspaceConsistency = + | 'caller_confirmed_quiescent' + | 'unverified'; +export type AgentSessionCheckpointRestoreReceiptOutput_AgentCheckpointWorkspaceWarning = + 'external_writers_not_fenced'; export type SettingsAssistantPreferencesOutput_LanguagePreference = | 'auto' | 'en' @@ -453,6 +700,27 @@ export interface ServiceMethods { input: Runtime.AgentChecklistRead; output: Runtime.AgentChecklist; }; + checkpointReceipt: { + mode: 'query'; + input: { + reference: AgentCheckpointReceiptInput_RecoveryRef; + /** + * The exact original input. Changed input cannot select another publication. + */ + input: AgentCheckpointReceiptInput_AgentCheckpointPublish; + }; + output: { + /** + * Original Host observation. Domain success never fabricates Host cleanup confirmation. + */ + operation?: AgentCheckpointReceiptOutput_OperationObservation | null; + /** + * Authoritative domain result even when the Host response was lost. + * Absence is not permission to recapture under a replacement operation. + */ + publication?: AgentCheckpointReceiptOutput_AgentCheckpointPublication | null; + }; + }; compact: { mode: 'mutation'; input: { @@ -589,25 +857,15 @@ export interface ServiceMethods { input: Runtime.AgentSessionRead; output: AgentHistoryCheckpointHeadOutput_AgentHistoryCheckpointHead | null; }; - historyCheckpointReceipt: { + historyCheckpointLoadReceipt: { mode: 'query'; input: { - reference: AgentHistoryCheckpointReceiptInput_RecoveryRef; - /** - * The exact original input. Changed input cannot select another publication. - */ - input: AgentHistoryCheckpointReceiptInput_AgentHistoryCheckpointPublish; + reference: AgentHistoryCheckpointLoadReceiptInput_RecoveryRef; + input: AgentHistoryCheckpointLoadReceiptInput_AgentHistoryCheckpointRead; }; output: { - /** - * Original Host observation. Domain success never fabricates Host cleanup confirmation. - */ - operation?: AgentHistoryCheckpointReceiptOutput_OperationObservation | null; - /** - * Authoritative domain result even when the Host response was lost. - * Absence is not permission to recapture under a replacement operation. - */ - publication?: AgentHistoryCheckpointReceiptOutput_AgentHistoryCheckpointPublication | null; + operation?: AgentHistoryCheckpointLoadReceiptOutput_OperationObservation | null; + load?: AgentHistoryCheckpointLoadReceiptOutput_AgentCheckpointLoad | null; }; }; historyLandmarks: { @@ -650,6 +908,53 @@ export interface ServiceMethods { input: Runtime.AgentFileListRead; output: Runtime.AgentFilePage; }; + loadHistoryCheckpoint: { + mode: 'mutation'; + input: { + sessionId: string; + /** + * Only the exact currently retained revision is readable; no latest fallback. + */ + revision: string; + }; + output: { + head: AgentLoadHistoryCheckpointOutput_AgentHistoryCheckpointHead; + inspection: AgentLoadHistoryCheckpointOutput_AgentCheckpointInspection; + state: AgentLoadHistoryCheckpointOutput_AgentCheckpointLoadState; + }; + }; + loadedHistoryCheckpointArtifact: { + mode: 'subscription'; + input: { + inspection: AgentLoadedHistoryCheckpointArtifactInput_AgentCheckpointInspection; + sessionId: string; + artifactId: string; + offset: number; + }; + output: AgentLoadedHistoryCheckpointArtifactOutput_AgentCheckpointArtifactChunk | null; + }; + loadedHistoryCheckpointArtifacts: { + mode: 'query'; + input: { + inspection: AgentLoadedHistoryCheckpointArtifactsInput_AgentCheckpointInspection; + sessionId: string; + offset: number; + }; + output: { + artifacts: AgentLoadedHistoryCheckpointArtifactsOutput_AgentCheckpointArtifact[]; + nextOffset: number | null; + }; + }; + loadedHistoryCheckpointHistory: { + mode: 'query'; + input: { + inspection: AgentLoadedHistoryCheckpointHistoryInput_AgentCheckpointInspection; + sessionId: string; + position: AgentLoadedHistoryCheckpointHistoryInput_HistoryPosition; + includeThinking?: boolean; + }; + output: Runtime.AgentHistoryPage; + }; manageMcpServer: { mode: 'mutation'; input: Runtime.AgentMcpConfigurationChange; @@ -764,7 +1069,7 @@ export interface ServiceMethods { input: Runtime.AgentProjectsRead; output: Runtime.AgentProjectsPage; }; - publishHistoryCheckpoint: { + publishCheckpoint: { mode: 'mutation'; input: { sessionId: string; @@ -773,13 +1078,19 @@ export interface ServiceMethods { * Membership confirmation returned by previewHistoryCheckpoint, not a live revision. */ confirmedSubtreeDigest: string; + /** + * Null captures history only. Otherwise the caller explicitly selects the + * Workspace consistency guarantee; other applications are never fenced. + */ + workspaceConsistency: AgentPublishCheckpointInput_AgentCheckpointWorkspaceConsistency | null; }; output: { - state: AgentPublishHistoryCheckpointOutput_AgentHistoryCheckpointState; + state: AgentPublishCheckpointOutput_AgentHistoryCheckpointState; /** * Cleanup failure does not undo a successful publication. */ cleanupPending: boolean; + workspace?: AgentPublishCheckpointOutput_AgentCheckpointWorkspaceNotice | null; }; }; queue: { @@ -848,6 +1159,31 @@ export interface ServiceMethods { input: Runtime.AgentInteractionRespond; output: Runtime.AgentInteractionResult; }; + restoreSessionCheckpoint: { + mode: 'mutation'; + /** + * Restore files into a new child of a selected directory and rebind the same, + * unchanged local Session. It never starts a model/tool call or rewinds history. + */ + input: { + checkpoint: AgentRestoreSessionCheckpointInput_AgentHistoryCheckpointRead; + /** + * Absolute parent path. The Host captures and validates its directory identity. + */ + parent: string; + permissions: AgentRestoreSessionCheckpointInput_AgentWorkspacePermissions; + backend: AgentRestoreSessionCheckpointInput_AgentBackendPreference; + acceptExternalWritersRisk: boolean; + }; + output: { + operation: string; + sessionId: string; + sessionRevision: number; + head: AgentRestoreSessionCheckpointOutput_AgentHistoryCheckpointHead; + workspace: AgentRestoreSessionCheckpointOutput_AgentCheckpointWorkspaceSnapshot; + path: string; + }; + }; resumable: { mode: 'query'; input: Runtime.AgentResumableRead; @@ -899,6 +1235,17 @@ export interface ServiceMethods { input: Runtime.AgentSend; output: Runtime.AgentSendResult; }; + sessionCheckpointRestoreReceipt: { + mode: 'query'; + input: { + reference: AgentSessionCheckpointRestoreReceiptInput_RecoveryRef; + input: AgentSessionCheckpointRestoreReceiptInput_AgentSessionCheckpointRestore; + }; + output: { + operation?: AgentSessionCheckpointRestoreReceiptOutput_OperationObservation | null; + restore?: AgentSessionCheckpointRestoreReceiptOutput_AgentSessionCheckpointRestored | null; + }; + }; sessionMcp: { mode: 'subscription'; /** @@ -1790,6 +2137,94 @@ export interface AgentBotOutboxOutput_AgentBotMessage { */ turnId?: string | null; } +export interface AgentCheckpointReceiptInput_RecoveryRef { + source: AgentCheckpointReceiptInput_DeploymentRef; + namespace: AgentCheckpointReceiptInput_InstanceId; + export: string; + method: string; + operation: string; + fingerprint: string; + contract: AgentCheckpointReceiptInput_RecoveryContract; + recovery: AgentCheckpointReceiptInput_RecoveryProtocol; +} +export interface AgentCheckpointReceiptInput_DeploymentRef { + stateDomain: string; + deployment: string; + revision: number; +} +/** + * Contract identity for a journal lookup. The original request retains the + * complete contract; a receipt carries only its digest. + */ +export interface AgentCheckpointReceiptInput_RecoveryContract { + name: string; + version: number; + transport: AgentCheckpointReceiptInput_Transport; + digest: string; +} +export interface AgentCheckpointReceiptInput_AgentCheckpointPublish { + sessionId: string; + expectedRevision: string | null; + /** + * Membership confirmation returned by previewHistoryCheckpoint, not a live revision. + */ + confirmedSubtreeDigest: string; + /** + * Null captures history only. Otherwise the caller explicitly selects the + * Workspace consistency guarantee; other applications are never fenced. + */ + workspaceConsistency: AgentCheckpointReceiptInput_AgentCheckpointWorkspaceConsistency | null; +} +export interface AgentCheckpointReceiptOutput_OperationObservation { + reference: AgentCheckpointReceiptOutput_RecoveryRef; + outcome: AgentCheckpointReceiptOutput_OperationOutcome; + cleanup: AgentCheckpointReceiptOutput_OperationCleanup; +} +export interface AgentCheckpointReceiptOutput_RecoveryRef { + source: AgentCheckpointReceiptOutput_DeploymentRef; + namespace: AgentCheckpointReceiptOutput_InstanceId; + export: string; + method: string; + operation: string; + fingerprint: string; + contract: AgentCheckpointReceiptOutput_RecoveryContract; + recovery: AgentCheckpointReceiptOutput_RecoveryProtocol; +} +export interface AgentCheckpointReceiptOutput_DeploymentRef { + stateDomain: string; + deployment: string; + revision: number; +} +/** + * Contract identity for a journal lookup. The original request retains the + * complete contract; a receipt carries only its digest. + */ +export interface AgentCheckpointReceiptOutput_RecoveryContract { + name: string; + version: number; + transport: AgentCheckpointReceiptOutput_Transport; + digest: string; +} +export interface AgentCheckpointReceiptOutput_AgentCheckpointPublication { + state: AgentCheckpointReceiptOutput_AgentHistoryCheckpointState; + /** + * Cleanup failure does not undo a successful publication. + */ + cleanupPending: boolean; + workspace?: AgentCheckpointReceiptOutput_AgentCheckpointWorkspaceNotice | null; +} +export interface AgentCheckpointReceiptOutput_AgentHistoryCheckpointHead { + revision: string; + manifest: AgentCheckpointReceiptOutput_AgentHistoryCheckpointObject; +} +export interface AgentCheckpointReceiptOutput_AgentHistoryCheckpointObject { + digest: string; + bytes: number; +} +export interface AgentCheckpointReceiptOutput_AgentCheckpointWorkspaceNotice { + consistency: AgentCheckpointReceiptOutput_AgentCheckpointWorkspaceConsistency; + warning: AgentCheckpointReceiptOutput_AgentCheckpointWorkspaceWarning; +} /** * Canonical physical execution receipt, independent of the current Session tip. */ @@ -1812,17 +2247,17 @@ export interface AgentHistoryCheckpointHeadOutput_AgentHistoryCheckpointObject { digest: string; bytes: number; } -export interface AgentHistoryCheckpointReceiptInput_RecoveryRef { - source: AgentHistoryCheckpointReceiptInput_DeploymentRef; - namespace: AgentHistoryCheckpointReceiptInput_InstanceId; +export interface AgentHistoryCheckpointLoadReceiptInput_RecoveryRef { + source: AgentHistoryCheckpointLoadReceiptInput_DeploymentRef; + namespace: AgentHistoryCheckpointLoadReceiptInput_InstanceId; export: string; method: string; operation: string; fingerprint: string; - contract: AgentHistoryCheckpointReceiptInput_RecoveryContract; - recovery: AgentHistoryCheckpointReceiptInput_RecoveryProtocol; + contract: AgentHistoryCheckpointLoadReceiptInput_RecoveryContract; + recovery: AgentHistoryCheckpointLoadReceiptInput_RecoveryProtocol; } -export interface AgentHistoryCheckpointReceiptInput_DeploymentRef { +export interface AgentHistoryCheckpointLoadReceiptInput_DeploymentRef { stateDomain: string; deployment: string; revision: number; @@ -1831,36 +2266,35 @@ export interface AgentHistoryCheckpointReceiptInput_DeploymentRef { * Contract identity for a journal lookup. The original request retains the * complete contract; a receipt carries only its digest. */ -export interface AgentHistoryCheckpointReceiptInput_RecoveryContract { +export interface AgentHistoryCheckpointLoadReceiptInput_RecoveryContract { name: string; version: number; - transport: AgentHistoryCheckpointReceiptInput_Transport; + transport: AgentHistoryCheckpointLoadReceiptInput_Transport; digest: string; } -export interface AgentHistoryCheckpointReceiptInput_AgentHistoryCheckpointPublish { +export interface AgentHistoryCheckpointLoadReceiptInput_AgentHistoryCheckpointRead { sessionId: string; - expectedRevision: string | null; /** - * Membership confirmation returned by previewHistoryCheckpoint, not a live revision. + * Only the exact currently retained revision is readable; no latest fallback. */ - confirmedSubtreeDigest: string; + revision: string; } -export interface AgentHistoryCheckpointReceiptOutput_OperationObservation { - reference: AgentHistoryCheckpointReceiptOutput_RecoveryRef; - outcome: AgentHistoryCheckpointReceiptOutput_OperationOutcome; - cleanup: AgentHistoryCheckpointReceiptOutput_OperationCleanup; +export interface AgentHistoryCheckpointLoadReceiptOutput_OperationObservation { + reference: AgentHistoryCheckpointLoadReceiptOutput_RecoveryRef; + outcome: AgentHistoryCheckpointLoadReceiptOutput_OperationOutcome; + cleanup: AgentHistoryCheckpointLoadReceiptOutput_OperationCleanup; } -export interface AgentHistoryCheckpointReceiptOutput_RecoveryRef { - source: AgentHistoryCheckpointReceiptOutput_DeploymentRef; - namespace: AgentHistoryCheckpointReceiptOutput_InstanceId; +export interface AgentHistoryCheckpointLoadReceiptOutput_RecoveryRef { + source: AgentHistoryCheckpointLoadReceiptOutput_DeploymentRef; + namespace: AgentHistoryCheckpointLoadReceiptOutput_InstanceId; export: string; method: string; operation: string; fingerprint: string; - contract: AgentHistoryCheckpointReceiptOutput_RecoveryContract; - recovery: AgentHistoryCheckpointReceiptOutput_RecoveryProtocol; + contract: AgentHistoryCheckpointLoadReceiptOutput_RecoveryContract; + recovery: AgentHistoryCheckpointLoadReceiptOutput_RecoveryProtocol; } -export interface AgentHistoryCheckpointReceiptOutput_DeploymentRef { +export interface AgentHistoryCheckpointLoadReceiptOutput_DeploymentRef { stateDomain: string; deployment: string; revision: number; @@ -1869,27 +2303,101 @@ export interface AgentHistoryCheckpointReceiptOutput_DeploymentRef { * Contract identity for a journal lookup. The original request retains the * complete contract; a receipt carries only its digest. */ -export interface AgentHistoryCheckpointReceiptOutput_RecoveryContract { +export interface AgentHistoryCheckpointLoadReceiptOutput_RecoveryContract { name: string; version: number; - transport: AgentHistoryCheckpointReceiptOutput_Transport; + transport: AgentHistoryCheckpointLoadReceiptOutput_Transport; digest: string; } -export interface AgentHistoryCheckpointReceiptOutput_AgentHistoryCheckpointPublication { - state: AgentHistoryCheckpointReceiptOutput_AgentHistoryCheckpointState; - /** - * Cleanup failure does not undo a successful publication. - */ - cleanupPending: boolean; +export interface AgentHistoryCheckpointLoadReceiptOutput_AgentCheckpointLoad { + head: AgentHistoryCheckpointLoadReceiptOutput_AgentHistoryCheckpointHead; + inspection: AgentHistoryCheckpointLoadReceiptOutput_AgentCheckpointInspection; + state: AgentHistoryCheckpointLoadReceiptOutput_AgentCheckpointLoadState; +} +export interface AgentHistoryCheckpointLoadReceiptOutput_AgentHistoryCheckpointHead { + revision: string; + manifest: AgentHistoryCheckpointLoadReceiptOutput_AgentHistoryCheckpointObject; +} +export interface AgentHistoryCheckpointLoadReceiptOutput_AgentHistoryCheckpointObject { + digest: string; + bytes: number; +} +export interface AgentHistoryCheckpointLoadReceiptOutput_AgentCheckpointInspection { + sourceSessionId: string; + operationId: string; } -export interface AgentHistoryCheckpointReceiptOutput_AgentHistoryCheckpointHead { +export interface AgentHistoryCheckpointLoadReceiptOutput_AgentCheckpointWorkspaceSnapshot { + identity: string; + consistency: AgentHistoryCheckpointLoadReceiptOutput_AgentCheckpointWorkspaceConsistency; + warning: AgentHistoryCheckpointLoadReceiptOutput_AgentCheckpointWorkspaceWarning; + inventory: AgentHistoryCheckpointLoadReceiptOutput_AgentHistoryCheckpointObject; +} +export interface AgentLoadHistoryCheckpointOutput_AgentHistoryCheckpointHead { revision: string; - manifest: AgentHistoryCheckpointReceiptOutput_AgentHistoryCheckpointObject; + manifest: AgentLoadHistoryCheckpointOutput_AgentHistoryCheckpointObject; } -export interface AgentHistoryCheckpointReceiptOutput_AgentHistoryCheckpointObject { +export interface AgentLoadHistoryCheckpointOutput_AgentHistoryCheckpointObject { digest: string; bytes: number; } +export interface AgentLoadHistoryCheckpointOutput_AgentCheckpointInspection { + sourceSessionId: string; + operationId: string; +} +export interface AgentLoadHistoryCheckpointOutput_AgentCheckpointWorkspaceSnapshot { + identity: string; + consistency: AgentLoadHistoryCheckpointOutput_AgentCheckpointWorkspaceConsistency; + warning: AgentLoadHistoryCheckpointOutput_AgentCheckpointWorkspaceWarning; + inventory: AgentLoadHistoryCheckpointOutput_AgentHistoryCheckpointObject; +} +export interface AgentLoadedHistoryCheckpointArtifactInput_AgentCheckpointInspection { + sourceSessionId: string; + operationId: string; +} +export interface AgentLoadedHistoryCheckpointArtifactOutput_AgentCheckpointArtifactChunk { + artifact: AgentLoadedHistoryCheckpointArtifactOutput_AgentCheckpointArtifact; + offset: number; + /** + * Base64, at most 32 KiB decoded. Empty only at the end. + */ + data: string; +} +export interface AgentLoadedHistoryCheckpointArtifactOutput_AgentCheckpointArtifact { + id: string; + name: string; + mimeType?: string | null; + bytes: number; +} +export interface AgentLoadedHistoryCheckpointArtifactsInput_AgentCheckpointInspection { + sourceSessionId: string; + operationId: string; +} +export interface AgentLoadedHistoryCheckpointArtifactsOutput_AgentCheckpointArtifact { + id: string; + name: string; + mimeType?: string | null; + bytes: number; +} +export interface AgentLoadedHistoryCheckpointHistoryInput_AgentCheckpointInspection { + sourceSessionId: string; + operationId: string; +} +/** + * Disposable presentation locator, never authority or a canonical execution fact. + * The source is the original emitting fact; the index precedes UI filtering. + */ +export interface AgentLoadedHistoryCheckpointHistoryInput_HistoryRowPosition { + sourceSequence: number; + itemIndex: number; +} +/** + * Byte offset in one message field's UTF-8 text, not a bearer capability. + */ +export interface AgentLoadedHistoryCheckpointHistoryInput_Cursor { + field?: AgentLoadedHistoryCheckpointHistoryInput_HistoryField & string; + position: AgentLoadedHistoryCheckpointHistoryInput_HistoryRowPosition; + offset: number; +} export interface AgentPreviewHistoryCheckpointOutput_AgentHistoryCheckpointSession { id: string; revision: number; @@ -1906,14 +2414,18 @@ export interface AgentProjectMcpOutput_AgentMcpServerView { preapproved: boolean; readOnlyTools: string[]; } -export interface AgentPublishHistoryCheckpointOutput_AgentHistoryCheckpointHead { +export interface AgentPublishCheckpointOutput_AgentHistoryCheckpointHead { revision: string; - manifest: AgentPublishHistoryCheckpointOutput_AgentHistoryCheckpointObject; + manifest: AgentPublishCheckpointOutput_AgentHistoryCheckpointObject; } -export interface AgentPublishHistoryCheckpointOutput_AgentHistoryCheckpointObject { +export interface AgentPublishCheckpointOutput_AgentHistoryCheckpointObject { digest: string; bytes: number; } +export interface AgentPublishCheckpointOutput_AgentCheckpointWorkspaceNotice { + consistency: AgentPublishCheckpointOutput_AgentCheckpointWorkspaceConsistency; + warning: AgentPublishCheckpointOutput_AgentCheckpointWorkspaceWarning; +} export interface AgentReadHistoryCheckpointOutput_AgentHistoryCheckpointHead { revision: string; manifest: AgentReadHistoryCheckpointOutput_AgentHistoryCheckpointObject; @@ -1931,6 +2443,163 @@ export interface AgentReadHistoryCheckpointOutput_AgentHistoryCheckpointSession id: string; revision: number; } +export interface AgentReadHistoryCheckpointOutput_AgentCheckpointWorkspaceSnapshot { + identity: string; + consistency: AgentReadHistoryCheckpointOutput_AgentCheckpointWorkspaceConsistency; + warning: AgentReadHistoryCheckpointOutput_AgentCheckpointWorkspaceWarning; + inventory: AgentReadHistoryCheckpointOutput_AgentHistoryCheckpointObject; +} +export interface AgentRestoreSessionCheckpointInput_AgentHistoryCheckpointRead { + sessionId: string; + /** + * Only the exact currently retained revision is readable; no latest fallback. + */ + revision: string; +} +export interface AgentRestoreSessionCheckpointInput_AgentWorkspacePermissions { + access: AgentRestoreSessionCheckpointInput_AgentWorkspaceAccess; + requestPermissions: boolean; +} +export interface AgentRestoreSessionCheckpointInput_AgentExecutorSelection { + executor: string; + settings: AgentRestoreSessionCheckpointInput_ExecutorSettings; +} +/** + * Executor-owned model selection, independent of Host model connections. + */ +export interface AgentRestoreSessionCheckpointInput_ExecutorSettings { + model?: string | null; + thinkingLevel?: AgentRestoreSessionCheckpointInput_ExecutorEffort | null; + runtime?: string | null; +} +export interface AgentRestoreSessionCheckpointOutput_AgentHistoryCheckpointHead { + revision: string; + manifest: AgentRestoreSessionCheckpointOutput_AgentHistoryCheckpointObject; +} +export interface AgentRestoreSessionCheckpointOutput_AgentHistoryCheckpointObject { + digest: string; + bytes: number; +} +export interface AgentRestoreSessionCheckpointOutput_AgentCheckpointWorkspaceSnapshot { + identity: string; + consistency: AgentRestoreSessionCheckpointOutput_AgentCheckpointWorkspaceConsistency; + warning: AgentRestoreSessionCheckpointOutput_AgentCheckpointWorkspaceWarning; + inventory: AgentRestoreSessionCheckpointOutput_AgentHistoryCheckpointObject; +} +export interface AgentSessionCheckpointRestoreReceiptInput_RecoveryRef { + source: AgentSessionCheckpointRestoreReceiptInput_DeploymentRef; + namespace: AgentSessionCheckpointRestoreReceiptInput_InstanceId; + export: string; + method: string; + operation: string; + fingerprint: string; + contract: AgentSessionCheckpointRestoreReceiptInput_RecoveryContract; + recovery: AgentSessionCheckpointRestoreReceiptInput_RecoveryProtocol; +} +export interface AgentSessionCheckpointRestoreReceiptInput_DeploymentRef { + stateDomain: string; + deployment: string; + revision: number; +} +/** + * Contract identity for a journal lookup. The original request retains the + * complete contract; a receipt carries only its digest. + */ +export interface AgentSessionCheckpointRestoreReceiptInput_RecoveryContract { + name: string; + version: number; + transport: AgentSessionCheckpointRestoreReceiptInput_Transport; + digest: string; +} +/** + * Restore files into a new child of a selected directory and rebind the same, + * unchanged local Session. It never starts a model/tool call or rewinds history. + */ +export interface AgentSessionCheckpointRestoreReceiptInput_AgentSessionCheckpointRestore { + checkpoint: AgentSessionCheckpointRestoreReceiptInput_AgentHistoryCheckpointRead; + /** + * Absolute parent path. The Host captures and validates its directory identity. + */ + parent: string; + permissions: AgentSessionCheckpointRestoreReceiptInput_AgentWorkspacePermissions; + backend: AgentSessionCheckpointRestoreReceiptInput_AgentBackendPreference; + acceptExternalWritersRisk: boolean; +} +export interface AgentSessionCheckpointRestoreReceiptInput_AgentHistoryCheckpointRead { + sessionId: string; + /** + * Only the exact currently retained revision is readable; no latest fallback. + */ + revision: string; +} +export interface AgentSessionCheckpointRestoreReceiptInput_AgentWorkspacePermissions { + access: AgentSessionCheckpointRestoreReceiptInput_AgentWorkspaceAccess; + requestPermissions: boolean; +} +export interface AgentSessionCheckpointRestoreReceiptInput_AgentExecutorSelection { + executor: string; + settings: AgentSessionCheckpointRestoreReceiptInput_ExecutorSettings; +} +/** + * Executor-owned model selection, independent of Host model connections. + */ +export interface AgentSessionCheckpointRestoreReceiptInput_ExecutorSettings { + model?: string | null; + thinkingLevel?: AgentSessionCheckpointRestoreReceiptInput_ExecutorEffort | null; + runtime?: string | null; +} +export interface AgentSessionCheckpointRestoreReceiptOutput_OperationObservation { + reference: AgentSessionCheckpointRestoreReceiptOutput_RecoveryRef; + outcome: AgentSessionCheckpointRestoreReceiptOutput_OperationOutcome; + cleanup: AgentSessionCheckpointRestoreReceiptOutput_OperationCleanup; +} +export interface AgentSessionCheckpointRestoreReceiptOutput_RecoveryRef { + source: AgentSessionCheckpointRestoreReceiptOutput_DeploymentRef; + namespace: AgentSessionCheckpointRestoreReceiptOutput_InstanceId; + export: string; + method: string; + operation: string; + fingerprint: string; + contract: AgentSessionCheckpointRestoreReceiptOutput_RecoveryContract; + recovery: AgentSessionCheckpointRestoreReceiptOutput_RecoveryProtocol; +} +export interface AgentSessionCheckpointRestoreReceiptOutput_DeploymentRef { + stateDomain: string; + deployment: string; + revision: number; +} +/** + * Contract identity for a journal lookup. The original request retains the + * complete contract; a receipt carries only its digest. + */ +export interface AgentSessionCheckpointRestoreReceiptOutput_RecoveryContract { + name: string; + version: number; + transport: AgentSessionCheckpointRestoreReceiptOutput_Transport; + digest: string; +} +export interface AgentSessionCheckpointRestoreReceiptOutput_AgentSessionCheckpointRestored { + operation: string; + sessionId: string; + sessionRevision: number; + head: AgentSessionCheckpointRestoreReceiptOutput_AgentHistoryCheckpointHead; + workspace: AgentSessionCheckpointRestoreReceiptOutput_AgentCheckpointWorkspaceSnapshot; + path: string; +} +export interface AgentSessionCheckpointRestoreReceiptOutput_AgentHistoryCheckpointHead { + revision: string; + manifest: AgentSessionCheckpointRestoreReceiptOutput_AgentHistoryCheckpointObject; +} +export interface AgentSessionCheckpointRestoreReceiptOutput_AgentHistoryCheckpointObject { + digest: string; + bytes: number; +} +export interface AgentSessionCheckpointRestoreReceiptOutput_AgentCheckpointWorkspaceSnapshot { + identity: string; + consistency: AgentSessionCheckpointRestoreReceiptOutput_AgentCheckpointWorkspaceConsistency; + warning: AgentSessionCheckpointRestoreReceiptOutput_AgentCheckpointWorkspaceWarning; + inventory: AgentSessionCheckpointRestoreReceiptOutput_AgentHistoryCheckpointObject; +} export interface AgentSkillCandidatesOutput_AgentSkillCandidate { reference: string; name: string; diff --git a/platform/event-log/Cargo.toml b/platform/event-log/Cargo.toml index e30bc7c7b3b..79f60c69908 100644 --- a/platform/event-log/Cargo.toml +++ b/platform/event-log/Cargo.toml @@ -24,6 +24,9 @@ publish.workspace = true autotests = false [dependencies] +ignore = "0.4.33" +cap-std.workspace = true +cap-fs-ext.workspace = true hex.workspace = true postcard.workspace = true maka-agent-facts.workspace = true diff --git a/platform/event-log/README.md b/platform/event-log/README.md index d84799e4c33..6e257f1526a 100644 --- a/platform/event-log/README.md +++ b/platform/event-log/README.md @@ -21,8 +21,8 @@ [简体中文](README.zh-CN.md) -History-only checkpoints use `RootStore::history_checkpoints` and the types in -[`checkpoints`](src/checkpoints.rs). A native Bundle and versioned manifest are +Checkpoints use `RootStore::history_checkpoints` and the types in +[`checkpoints`](src/checkpoints.rs). A native Bundle and manifest are verified and durably stored under the private Root before a separate checkpoint Head is created or conditionally advanced. Head and publication receipt commit in the Session's partition transaction; the Host operation journal can be in a @@ -41,12 +41,14 @@ Historical success receipts do not grant access to superseded revisions. This internal facility currently requires Unix file/directory durability; unsupported platforms reject publication before claiming an operation. It has no -automatic trigger, wire endpoint, workspace file snapshot, execution grant or -restore/activation path. The format is native history, not the legacy TS -compatibility bundle. Immutable objects are conservatively retained; only owned +automatic trigger, wire endpoint or cross-Root execution handoff. The native +formats are distinct from the legacy TS compatibility bundle; workspace capture +and same-Root directory recovery are described below. Immutable objects are conservatively retained; only owned temporary files are cleaned. Storage usage is therefore not automatically bounded, and object retention/GC remains separate work. +`HistoryCheckpoints::load_exact` pins an exact current Head and imports its validated native bundle into a private inspection database. The load journal lives beside the source Head; the destination native import receipt reconciles an uncertain import without overwriting live history. Root startup processes pending loads in bounded batches from their original materials. Inspections preserve historical IDs but have no live routes, execution configuration or scheduler. Only bounded history/artifact queries are exposed; the private `EventLog` is never returned. Physical workers retain Root custody until target SQL is closed. Loaded inspections remain readable after Head advances and are conservatively retained; there is no inspection GC yet. + Transcript rows expose separate `position` and `order` addresses. `position` locates canonical source content; `order` places a native assistant message at its actual start even when its end arrives after a tool call. Indexed history paging, navigation and copied history use display order while source reads and fences retain canonical provenance. Native message bodies are reconstructed through invocation/message-specific indexes, not by rescanning all earlier tool output. Stores initialize through the current SQL migrations. Unsupported internal layouts and record formats are rejected. Canonical facts and their acceptance, ownership, recovery and budget indexes share the original transaction. Transcript and Recall read models belong to the disposable observation cache. @@ -87,7 +89,6 @@ A statement failure rolls back the entire group unless it is a domain rejection Saved application inspection uses `RootOwner::open_existing` and `EventLog::for_application_inspection`. It requires existing private coordination state and the original exclusive Root/database leases, reads committed application facts including retained WAL content, and closes without changing stored members or bytes. It performs no migration or derived initialization; missing state fails without repair. Only application queries on the owned connection belong to this opening. CLI application read and default-update preview share it; applying an update opens a fresh ordinary writer and recomputes the change. - Original operation contracts are stored once by digest. Operation requests and receipt references carry that identity, so journal reads, caller history and UI delivery do not repeatedly copy service schemas. Reads verify the exact original request fingerprint; admission still validates the complete contract and resource requirements. The operation journal retains complete admitted requests and returned values without separate request, result or receipt-reference size quotas. Exact retries and reopened readers recover the original outcome and cleanup status. @@ -121,3 +122,12 @@ Integration scenarios share `cargo test -p maka-event-log --test integration`; f [Source](src/lib.rs) · [Cargo manifest](Cargo.toml) Startup recovery reads all unresolved execution authority without a separate item or byte quota; completed history stays excluded. Tool-result payloads and frozen request compositions have no application-level storage size cap. The schema migration preserves existing payloads and composition references. + + +Workspace snapshots use a path inventory plus immutable file objects. Their +`caller_confirmed_quiescent` / `unverified` declaration is independent of byte +integrity and never claims external-writer fencing. Same-Root workspace restore +requires unchanged live history, materializes a new directory, and commits the +binding and original receipt atomically without advancing checkpoint Head or +starting execution. See the [Agent API](../../plugins/agent/api/README.md#workspace-publication-and-local-continuation) +for the supported file policy, explicit warning, recovery and scope limits. diff --git a/platform/event-log/README.zh-CN.md b/platform/event-log/README.zh-CN.md index 392c8613cac..110d2fafd34 100644 --- a/platform/event-log/README.zh-CN.md +++ b/platform/event-log/README.zh-CN.md @@ -23,8 +23,8 @@ Transcript 行分别暴露 `position` 和 `order`:前者定位规范原文,后者按原生 assistant 消息真正开始的位置排序,即使消息结束记录晚于工具调用。历史分页、导航与复制历史按显示顺序使用索引,原文读取和 fence 仍保留规范来源。原生消息正文按 invocation/message 专用索引重建,不逐条重扫此前所有工具输出。 -仅历史 checkpoint 使用 `RootStore::history_checkpoints` 和 -[`checkpoints`](src/checkpoints.rs) 中的类型。原生 Bundle 与版本化 Manifest +Checkpoint 使用 `RootStore::history_checkpoints` 和 +[`checkpoints`](src/checkpoints.rs) 中的类型。原生 Bundle 与 Manifest 先验证并持久保存在私有 Root 内,再创建或条件更新独立的 checkpoint Head。 Head 与发布回执在 Session 所在分区的同一事务提交;Host 操作日志可能位于 另一分区,响应结果未知时必须先查询原发布回执。实时 Session revision 和 @@ -38,11 +38,13 @@ Root 直到工作结束。精确读取只支持当前 retained checkpoint revisi 替换成最新版本;历史成功回执也不授予已被替代版本的读取能力。 该内部能力当前要求 Unix 文件及目录持久化语义,其他平台在请求 claim 前 -拒绝发布。它不提供自动触发、协议入口、Workspace 文件快照、执行授权或恢复 -启动;原生历史格式不兼容旧 TS compatibility bundle。不可变对象暂时保守 +拒绝发布。它不提供自动触发、协议入口或跨 Root 执行交接;Workspace 捕获及 +同 Root 目录恢复见下文。原生格式不兼容旧 TS compatibility bundle。不可变对象暂时保守 保留,只清理明确归属的临时文件,因此磁盘使用并非自动有界;retention/GC 属于后续工作。 +`HistoryCheckpoints::load_exact` 固定指定的当前 Head,将校验后的原生 Bundle 导入私有检查数据库。加载日志与源 Head 同分区;目标原生导入回执用于消歧,不覆盖实时历史。Root 启动时分批使用原材料恢复未完成加载。检查副本保留历史 ID,但没有实时路由、执行配置或调度器;仅暴露有界历史和 Artifact 查询,不返回私有 `EventLog`。物理 worker 持有 Root 直到目标 SQL 关闭。已加载副本在 Head 推进后仍可读,保守保留,尚无检查副本 GC。 + 存储通过当前 SQL 迁移初始化;不支持的内部布局和记录格式会被拒绝。规范事实及其接受、所有权、恢复和预算索引共享原事务;transcript 与 Recall 读模型属于可丢弃的 observation 缓存。 管理 SQLite 事件存储和 State Root 租约,持久化应用配置与包、操作回执、会话、轮次及恢复记录。独占所有权见 `root`,已提交的组合见 `applications`,中断恢复见 `recovery`。服务激活前必须先取得 Root 的持久化所有权;业务策略属于插件,规范事实由这里记录。 @@ -81,7 +83,6 @@ Control 中的调用意图、项目登记、workspace attachment、Session 路 已保存应用的检查使用 `RootOwner::open_existing` 和 `EventLog::for_application_inspection`,要求已有的私有协调状态,并持有原有的独占 Root/数据库租约。检查读取已提交的应用事实,包括保留在 WAL 中的内容,关闭后不改变存储成员或字节;不执行迁移或派生初始化,缺失状态直接报错,不做修复。该打开方式仅供原连接上的应用查询使用。CLI 的 application read 与默认更新预览共用此入口;实际应用更新时重新打开普通写入所有者,并重新计算变更。 - 原始操作契约按摘要只存储一次。操作请求和回执引用只携带该身份,日志读取、调用方历史与 UI 传递不再逐次复制服务 schema。读取仍核对原始请求指纹,执行准入仍校验完整契约和资源要求。 操作日志完整保存已受理的请求和返回值,不另设请求、结果或回执引用的大小额度。精确重试和重开后的读取恢复原始结果与清理状态。 @@ -115,3 +116,6 @@ Recall 在 [FTS5](https://sqlite.org/fts5.html#the_detail_option) 中记录每 [源码](src/lib.rs) · [Cargo 配置](Cargo.toml) 启动恢复读取全部未决执行状态,不另设条数或字节配额,已完成历史仍不参与扫描。工具结果 payload 与冻结请求配置不设应用层存储大小上限,数据库迁移保留已有内容和配置引用。 + + +Workspace 存档采用路径清单和不可变文件对象;`caller_confirmed_quiescent` / `unverified` 声明与字节完整性分开,不能宣称已经隔离外部写入。同 Root 目录恢复要求实时历史未推进,在新目录物化,并原子提交绑定与原回执;不推进 checkpoint Head、不自动执行。文件策略、明确风险提示、故障恢复和范围见 [Agent API](../../plugins/agent/api/README.zh-CN.md)。 diff --git a/platform/event-log/migrations/0019_checkpoint_loads.sql b/platform/event-log/migrations/0019_checkpoint_loads.sql new file mode 100644 index 00000000000..db7dc3ddf45 --- /dev/null +++ b/platform/event-log/migrations/0019_checkpoint_loads.sql @@ -0,0 +1,32 @@ +-- 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. + +CREATE TABLE history_checkpoint_loads ( + load_key TEXT PRIMARY KEY NOT NULL, + request_json TEXT NOT NULL CHECK(json_valid(request_json)), + result_json TEXT NOT NULL CHECK(json_valid(result_json)) +); +CREATE INDEX history_checkpoint_load_recovery ON history_checkpoint_loads(load_key) +WHERE json_extract(result_json,'$.state.kind')='preparing'; + +CREATE TABLE checkpoint_workspace_restores ( + restore_key TEXT PRIMARY KEY NOT NULL, + request_json TEXT NOT NULL CHECK(json_valid(request_json)), + prepared_json TEXT NOT NULL CHECK(json_valid(prepared_json)), + directory_identity_json TEXT CHECK(directory_identity_json IS NULL OR json_valid(directory_identity_json)), + result_json TEXT CHECK(result_json IS NULL OR json_valid(result_json)) +); diff --git a/platform/event-log/src/bundle.rs b/platform/event-log/src/bundle.rs index 76fd44f85fd..598ce0d5203 100644 --- a/platform/event-log/src/bundle.rs +++ b/platform/event-log/src/bundle.rs @@ -38,6 +38,7 @@ mod relocation; mod stage; mod validation; pub use export::BundleSummary; +pub(crate) use export::{history_cut, require_idle}; pub use reader::inspect; pub use stage::StagedBundle; diff --git a/platform/event-log/src/bundle/export.rs b/platform/event-log/src/bundle/export.rs index 880f83810b7..785fd8956b8 100644 --- a/platform/event-log/src/bundle/export.rs +++ b/platform/event-log/src/bundle/export.rs @@ -132,7 +132,10 @@ async fn events( } } -async fn require_idle(db: &mut SqliteConnection, inventory: &Inventory) -> Result<(), StoreError> { +pub(crate) async fn require_idle( + db: &mut SqliteConnection, + inventory: &Inventory, +) -> Result<(), StoreError> { for session in &inventory.sessions { if crate::sessions::has_active_invocation(db, &session.id).await? { return Err(StoreError::SessionBusy); @@ -151,6 +154,24 @@ async fn require_idle(db: &mut SqliteConnection, inventory: &Inventory) -> Resul Ok(()) } +pub(crate) async fn history_cut( + db: &mut SqliteConnection, + session: &str, +) -> Result { + use sha2::{Digest, Sha256}; + let mut hash = Sha256::new(); + let mut rows = sqlx::query_scalar::<_, i64>( + "SELECT sequence FROM runtime_events WHERE event_session=?1 + UNION SELECT sequence FROM session_history_members WHERE session_id=?1 ORDER BY sequence", + ) + .bind(session) + .fetch(db); + while let Some(sequence) = rows.try_next().await? { + hash.update(sequence_number(sequence)?.to_le_bytes()); + } + Ok(hex::encode(hash.finalize())) +} + async fn catalog( db: &mut SqliteConnection, inventory: &Inventory, diff --git a/platform/event-log/src/bundle/stage.rs b/platform/event-log/src/bundle/stage.rs index 1f63c465ed1..573b6eef817 100644 --- a/platform/event-log/src/bundle/stage.rs +++ b/platform/event-log/src/bundle/stage.rs @@ -134,6 +134,31 @@ impl StagedBundle { } } + pub(crate) async fn history_cut(&mut self, session: &str) -> Result { + use futures_util::TryStreamExt; + use sha2::{Digest, Sha256}; + let mut hash = Sha256::new(); + let mut rows = sqlx::query_scalar::<_, i64>( + "SELECT sequence FROM ( + SELECT CAST(json_extract(record_json,'$.sequence') AS INTEGER) AS sequence + FROM frames + WHERE kind='member' AND json_extract(record_json,'$.session')=?1 + UNION + SELECT source_sequence AS sequence + FROM frames + WHERE kind='event' + AND json_extract(json_extract(record_json,'$.json'),'$.invocation.session_id')=?1 + ) + ORDER BY sequence", + ) + .bind(session) + .fetch(&mut self.database); + while let Some(sequence) = rows.try_next().await? { + hash.update(crate::sequence_number(sequence)?.to_le_bytes()); + } + Ok(hex::encode(hash.finalize())) + } + pub fn summary(&self) -> &BundleSummary { &self.summary } diff --git a/platform/event-log/src/checkpoints.rs b/platform/event-log/src/checkpoints.rs index 79429f0d0fb..f96142f8e49 100644 --- a/platform/event-log/src/checkpoints.rs +++ b/platform/event-log/src/checkpoints.rs @@ -17,12 +17,19 @@ * under the License. */ -//! Immutable history-only checkpoints. These are not workspace snapshots or -//! execution grants, and their revisions are independent of live Session revisions. +//! Immutable history and Workspace checkpoints. Conditional consistency is not an +//! execution grant; checkpoint revisions are independent of live Session revisions. +mod load; +mod restore; +mod workspace; use crate::{StoreError, bundle::Inventory}; +pub use load::{LoadOperation, LoadState, LoadedCheckpoint}; +pub(crate) use load::{LoadRequest, load_key}; use maka_plugin_core::composition::InstanceId; +pub use restore::{PreparedRestore, RestoreOperation, RestoredSession}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +pub use workspace::{WorkspaceCapture, WorkspaceConsistency, WorkspaceEntry, WorkspaceSnapshot}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -45,6 +52,8 @@ pub struct PublicationRequest { pub expected_revision: Option, /// Confirmation of catalog membership, not a live revision precondition. pub confirmed_subtree_digest: String, + /// Trusted assembly supplies the source; never a model-authored path. + pub workspace: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -56,24 +65,23 @@ pub struct ObjectRef { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", deny_unknown_fields)] -pub enum Manifest { - #[serde(rename = "native_history_v1")] - NativeHistory { - root_id: String, - scope: Scope, - inventory: Inventory, - /// MAKA-SESSION version 2; no TS tar.zst compatibility is implied. - bundle: ObjectRef, - /// The native format's own framed-stream checksum. - transfer_digest: String, - }, +#[serde(deny_unknown_fields)] +pub struct Manifest { + pub root_id: String, + pub scope: Scope, + pub inventory: Inventory, + /// MAKA-SESSION version 2; no TS tar.zst compatibility is implied. + pub bundle: ObjectRef, + pub transfer_digest: String, + pub history_cut: String, + pub workspace: Option, } impl Manifest { pub fn bundle(&self) -> &ObjectRef { - match self { - Self::NativeHistory { bundle, .. } => bundle, - } + &self.bundle + } + pub fn workspace(&self) -> Option<&WorkspaceSnapshot> { + self.workspace.as_ref() } } diff --git a/platform/event-log/src/checkpoints/load.rs b/platform/event-log/src/checkpoints/load.rs new file mode 100644 index 00000000000..77efe272fe3 --- /dev/null +++ b/platform/event-log/src/checkpoints/load.rs @@ -0,0 +1,88 @@ +/* + * 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. + */ + +//! A loaded checkpoint is isolated history, never a runnable Session. +use super::*; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LoadOperation { + pub operation: Uuid, + pub fingerprint: String, + pub revision: Revision, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum LoadState { + Preparing, + Ready { + session_ids: Vec, + through: u64, + transfer_digest: String, + workspace: Option, + }, + Unavailable, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LoadedCheckpoint { + pub head: Head, + pub state: LoadState, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LoadRequest { + pub scope: Scope, + pub session_id: String, + pub load: LoadOperation, +} +impl LoadRequest { + pub(crate) fn validate(&self) -> Result<(), StoreError> { + crate::sessions::validate_id(&self.session_id)?; + self.scope + .namespace + .validate() + .map_err(|e| invalid(e.to_string()))?; + if self.scope.deployment.is_nil() + || !valid_digest(&self.scope.state_domain) + || self.load.operation.is_nil() + || self.load.revision.0.is_nil() + || !valid_digest(&self.load.fingerprint) + { + return Err(invalid("invalid checkpoint load request")); + } + Ok(()) + } + pub(crate) fn key(&self) -> Result { + load_key(&self.scope, &self.session_id, self.load.operation) + } + pub(crate) fn binding(&self) -> Result { + Ok(format!("sha256:{}", digest(&serde_json::to_vec(self)?))) + } +} +pub(crate) fn load_key( + scope: &Scope, + session: &str, + operation: Uuid, +) -> Result { + Ok(digest(&serde_json::to_vec(&(scope, session, operation))?)) +} diff --git a/platform/event-log/src/checkpoints/restore.rs b/platform/event-log/src/checkpoints/restore.rs new file mode 100644 index 00000000000..5acbaf26a52 --- /dev/null +++ b/platform/event-log/src/checkpoints/restore.rs @@ -0,0 +1,46 @@ +/* + * 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. + */ + +//! Same-Root recovery at the captured, completed Session revision. +use super::*; +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RestoreOperation { + pub operation: Uuid, + pub fingerprint: String, + pub revision: Revision, + pub parent: std::path::PathBuf, + pub parent_identity: maka_agent_facts::execution::DirectoryIdentity, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PreparedRestore { + pub head: Head, + pub history_cut: String, + pub workspace: WorkspaceSnapshot, + pub path: std::path::PathBuf, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RestoredSession { + pub operation: Uuid, + pub session_id: String, + pub session_revision: u64, + pub prepared: PreparedRestore, +} diff --git a/platform/event-log/src/checkpoints/workspace.rs b/platform/event-log/src/checkpoints/workspace.rs new file mode 100644 index 00000000000..db0f4a81f29 --- /dev/null +++ b/platform/event-log/src/checkpoints/workspace.rs @@ -0,0 +1,59 @@ +/* + * 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. + */ + +use super::*; +use std::path::PathBuf; + +/// Neither variant asserts an OS-enforced point-in-time filesystem snapshot. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceConsistency { + CallerConfirmedQuiescent, + Unverified, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceCapture { + pub root: PathBuf, + pub identity: String, + pub consistency: WorkspaceConsistency, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceSnapshot { + pub identity: String, + pub consistency: WorkspaceConsistency, + /// Path inventory; file bodies are separately verified immutable objects. + pub inventory: ObjectRef, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum WorkspaceEntry { + Directory { + path: String, + }, + File { + path: String, + object: ObjectRef, + executable: bool, + }, +} diff --git a/platform/event-log/src/lib.rs b/platform/event-log/src/lib.rs index 7c5cc86a5f9..eefc4344b81 100644 --- a/platform/event-log/src/lib.rs +++ b/platform/event-log/src/lib.rs @@ -207,6 +207,41 @@ enum OpenPurpose { } impl EventLog { + /// A completed checkpoint inspection is immutable and never enters execution. + /// The caller holds its shared checkpoint gate through the physical close. + async fn open_checkpoint_inspection( + path: &Path, + root: Arc, + through: u64, + ) -> Result { + let mut wal = path.as_os_str().to_os_string(); + wal.push("-wal"); + match std::fs::metadata(Path::new(&wal)) { + Ok(metadata) if metadata.len() != 0 => return Err(StoreError::WriterBusy), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let connection = OwnedConnection::open( + SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false) + .read_only(true) + .immutable(true) + .query_planner_stability(true), + ConnectionAuthority::Root(root.clone()), + application_inspection::initialize, + ) + .await?; + Ok(Self { + expected_session: None, + connection, + root_owner: Some(root), + commits: commits::CommitHub::new(through), + shell_changes: tokio::sync::broadcast::channel(256).0, + }) + } + /// Observe canonical SQLite actors. pub async fn sqlite_resource_stats( &self, diff --git a/platform/event-log/src/root_store.rs b/platform/event-log/src/root_store.rs index 886585c5c8a..04c94069ec2 100644 --- a/platform/event-log/src/root_store.rs +++ b/platform/event-log/src/root_store.rs @@ -42,7 +42,7 @@ pub use execution::{ pub(crate) use lifecycle::{StageOwner, StageRegistration}; mod applications; mod attachments; -mod checkpoints; +pub(crate) mod checkpoints; mod computer_apps; mod control; pub use checkpoints::{Checkout, HistoryCheckpoints}; @@ -84,6 +84,7 @@ pub struct RootStore { partitions: Vec>, closed: Arc, gates: Arc, + inspections: Arc, lifecycle: Option, project_mutations: Arc>, } @@ -188,6 +189,7 @@ impl RootStore { partitions: Vec::with_capacity(PARTITION_COUNT), closed: Arc::new(AtomicBool::new(false)), gates: gates::Gates::new(), + inspections: checkpoints::load::Inspections::new(), lifecycle: None, project_mutations: Arc::new(tokio::sync::Mutex::new(())), }; @@ -218,11 +220,15 @@ impl RootStore { let logs = std::iter::once(store.control.clone()) .chain(store.partitions.iter().cloned()) .collect(); - store.lifecycle = Some(lifecycle::Lifecycle::start(logs, store.gates.clone()).await?); + store.lifecycle = Some( + lifecycle::Lifecycle::start(logs, store.gates.clone(), store.inspections.clone()) + .await?, + ); // Recovery also owns real workers. Install their independent close // observer before recovery can retain a failed SQL job or stage. store.recover_creations().await?; store.recover_checkpoints().await?; + store.recover_checkpoint_loads().await?; // No admission or retirement can observe work abandoned by the // previous owner as live after a process restart. store.recover_host_effects().await?; @@ -281,6 +287,7 @@ impl RootStore { self.closed.store(true, Ordering::Release); self.gates.close(); if self.lifecycle.is_none() { + self.inspections.request_shutdown(); self.control.request_shutdown(); for partition in &self.partitions { partition.request_shutdown(); @@ -296,9 +303,11 @@ impl RootStore { // Bootstrap failures have not installed a lifecycle owner yet. // Observe every real close even if an earlier owner fails. Each failed // owner retains its original authority, so it cannot be replaced. + let cached = self.inspections.take_all(); let results = futures_util::future::join_all( std::iter::once(&self.control) .chain(&self.partitions) + .chain(&cached) .map(|log| log.shutdown()), ) .await; diff --git a/platform/event-log/src/root_store/checkpoints.rs b/platform/event-log/src/root_store/checkpoints.rs index 7c5b4519a2a..76352f8a69e 100644 --- a/platform/event-log/src/root_store/checkpoints.rs +++ b/platform/event-log/src/root_store/checkpoints.rs @@ -27,13 +27,17 @@ use crate::checkpoints::{ }; use std::future::Future; mod journal; +pub(in crate::root_store) mod load; mod objects; +pub(crate) mod restore; +mod workspace; use objects::Objects; #[derive(Clone)] pub struct HistoryCheckpoints { session: SessionStore, scope: Scope, + inspections: Arc, } #[derive(Debug)] @@ -59,6 +63,7 @@ impl RootStore { Ok(HistoryCheckpoints { session: self.session(session).await?, scope, + inspections: self.inspections.clone(), }) } @@ -100,6 +105,13 @@ impl RootStore { } impl HistoryCheckpoints { + /// Resolve trusted source binding before consulting a possibly changed live Session. + pub async fn original_request( + &self, + publication: PublicationRequest, + ) -> Result, StoreError> { + journal::original(&self.session.scoped().await?, self.request(publication)?).await + } fn request(&self, publication: PublicationRequest) -> Result { let request = Request { scope: self.scope.clone(), @@ -185,39 +197,49 @@ impl HistoryCheckpoints { log, self.session.gates.clone(), move |log, objects| async move { - let head = journal::read_head(&log, scope.clone(), session.clone()) - .await? - .filter(|head| head.revision == revision) - .ok_or_else(|| invalid("history checkpoint revision unavailable"))?; - let manifest = objects.manifest(&head.manifest)?; - let Manifest::NativeHistory { - root_id, - scope: origin, - inventory, - .. - } = &manifest; - if root_id != objects.root_id() - || origin != &scope - || inventory.root_session_id != session - { - return Err(invalid("history checkpoint origin differs")); - } - let bundle = objects.read(manifest.bundle())?; - // Confirm the retained revision again after expensive verification. - if journal::read_head(&log, scope, session).await?.as_ref() != Some(&head) { - return Err(invalid("history checkpoint revision unavailable")); - } - Ok(Checkout { - head, - manifest, - bundle, - }) + checkout(&log, &objects, scope, session, revision).await }, ) .await } } +async fn checkout( + log: &EventLog, + objects: &Objects, + scope: Scope, + session: String, + revision: Revision, +) -> Result { + let head = journal::read_head(log, scope.clone(), session.clone()) + .await? + .filter(|head| head.revision == revision) + .ok_or_else(|| invalid("history checkpoint revision unavailable"))?; + let manifest = objects.manifest(&head.manifest)?; + let Manifest { + root_id, + scope: origin, + inventory, + .. + } = &manifest; + if root_id != objects.root_id() || origin != &scope || inventory.root_session_id != session { + return Err(invalid("history checkpoint origin differs")); + } + let bundle = objects.read(manifest.bundle())?; + if let Some(snapshot) = manifest.workspace() { + workspace::verify(objects, snapshot)?; + } + // Confirm the retained revision again after expensive verification. + if journal::read_head(log, scope, session).await?.as_ref() != Some(&head) { + return Err(invalid("history checkpoint revision unavailable")); + } + Ok(Checkout { + head, + manifest, + bundle, + }) +} + fn bundle_error(error: BundleError) -> StoreError { match error { BundleError::Store(error) => error, @@ -243,19 +265,45 @@ async fn capture( drop(file); let bundle = objects.publish(&objects.staging(request, "bundle")?)?; boundary("payload"); - let manifest = Manifest::NativeHistory { + let mut staged = StagedBundle::read(tokio::fs::File::from_std(objects.read(&bundle)?)) + .await + .map_err(bundle_error)?; + let validated = async { + staged.validate_history().await.map_err(bundle_error)?; + if staged.summary().inventory != summary.inventory + || staged.summary().digest != summary.digest + { + return Err(invalid("checkpoint captured bundle differs")); + } + staged.history_cut(&request.session_id).await + } + .await; + let closed = staged.close().await.map_err(bundle_error); + let history_cut = validated?; + closed?; + let workspace = request + .publication + .workspace + .as_ref() + .map(|source| workspace::capture(objects, request, source)) + .transpose()?; + let manifest = Manifest { root_id: objects.root_id().into(), scope: request.scope.clone(), inventory: summary.inventory, bundle, transfer_digest: summary.digest, + history_cut, + workspace, }; - validate_bundle(objects, &manifest).await?; objects.publish_manifest(request, &manifest) } async fn validate_bundle(objects: &Objects, manifest: &Manifest) -> Result<(), StoreError> { - let Manifest::NativeHistory { + if let Some(workspace) = manifest.workspace() { + workspace::verify(objects, workspace)?; + } + let Manifest { inventory, bundle, transfer_digest, @@ -270,7 +318,11 @@ async fn validate_bundle(objects: &Objects, manifest: &Manifest) -> Result<(), S { return Err(invalid("checkpoint manifest and bundle differ")); } - staged.validate_history().await.map_err(bundle_error) + staged.validate_history().await.map_err(bundle_error)?; + if staged.history_cut(&inventory.root_session_id).await? != manifest.history_cut { + return Err(invalid("checkpoint history cut differs from bundle")); + } + Ok(()) } .await; let closed = staged.close().await.map_err(bundle_error); @@ -283,7 +335,7 @@ async fn verify( reference: &ObjectRef, ) -> Result<(), StoreError> { let manifest = objects.manifest(reference)?; - let Manifest::NativeHistory { + let Manifest { root_id, scope, inventory, @@ -365,6 +417,33 @@ async fn worker( gates: Arc, action: F, ) -> Result +where + T: Send + 'static, + F: FnOnce(EventLog, Objects) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + physical_worker(log, gates, Objects::open, action).await +} + +async fn read_worker( + log: EventLog, + gates: Arc, + action: F, +) -> Result +where + T: Send + 'static, + F: FnOnce(EventLog, Objects) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + physical_worker(log, gates, Objects::existing, action).await +} + +async fn physical_worker( + log: EventLog, + gates: Arc, + open: fn(Arc) -> Result, + action: F, +) -> Result where T: Send + 'static, F: FnOnce(EventLog, Objects) -> Fut + Send + 'static, @@ -386,7 +465,7 @@ where let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build()?; - let objects = Objects::open(root)?; + let objects = open(root)?; runtime.block_on(action(log, objects)) })(); work.complete = true; diff --git a/platform/event-log/src/root_store/checkpoints/journal.rs b/platform/event-log/src/root_store/checkpoints/journal.rs index 1cad7c5a877..25529740e8b 100644 --- a/platform/event-log/src/root_store/checkpoints/journal.rs +++ b/platform/event-log/src/root_store/checkpoints/journal.rs @@ -90,6 +90,51 @@ pub(super) async fn read( .await } +pub(super) async fn original( + log: &EventLog, + request: Request, +) -> Result, StoreError> { + log.connection + .read(move |db| { + Box::pin(async move { + binding(db, &request).await?; + let original: Option = sqlx::query_scalar( + "SELECT request_json FROM history_checkpoint_publications WHERE request_key=?", + ) + .bind(request.key()?) + .fetch_optional(db) + .await?; + original + .map(|json| { + let original: Request = serde_json::from_str(&json)?; + if original.scope != request.scope + || original.session_id != request.session_id + || original.publication.fingerprint != request.publication.fingerprint + || original.publication.expected_revision + != request.publication.expected_revision + || original.publication.confirmed_subtree_digest + != request.publication.confirmed_subtree_digest + || original + .publication + .workspace + .as_ref() + .map(|v| v.consistency) + != request + .publication + .workspace + .as_ref() + .map(|v| v.consistency) + { + return Err(invalid("checkpoint operation inputs differ")); + } + Ok(original.publication) + }) + .transpose() + }) + }) + .await +} + pub(super) async fn read_head( log: &EventLog, scope: Scope, diff --git a/platform/event-log/src/root_store/checkpoints/load.rs b/platform/event-log/src/root_store/checkpoints/load.rs new file mode 100644 index 00000000000..9735a88a2e6 --- /dev/null +++ b/platform/event-log/src/root_store/checkpoints/load.rs @@ -0,0 +1,645 @@ +/* + * 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. + */ + +//! Checkpoint inspections never enter the live Root's Session routes or recovery queues. +use super::*; +use crate::checkpoints::{LoadOperation, LoadRequest, LoadState, LoadedCheckpoint, load_key}; +use maka_agent_ports::session::history::{Page, Position}; +use sqlx::Connection; +use std::collections::BTreeMap; +use uuid::Uuid; + +const INSPECTION_CACHE_CAPACITY: usize = 8; + +struct CachedInspection { + key: String, + log: Arc, + through: u64, + session_ids: Vec, +} + +enum CacheInsert { + Cached { + active: Arc, + retired: Option>, + }, + Uncached(Arc), +} + +#[derive(Default)] +pub(in crate::root_store) struct Inspections { + entries: std::sync::Mutex>>, +} + +impl Inspections { + pub(in crate::root_store) fn new() -> Arc { + Arc::new(Self::default()) + } + + fn get(&self, key: &str) -> Option> { + let mut entries = self.entries.lock().unwrap(); + let position = entries.iter().position(|entry| entry.key == key)?; + let entry = entries.remove(position); + entries.push(entry.clone()); + Some(entry) + } + + fn insert(&self, candidate: Arc) -> CacheInsert { + let mut entries = self.entries.lock().unwrap(); + if let Some(position) = entries.iter().position(|entry| entry.key == candidate.key) { + let existing = entries.remove(position); + entries.push(existing.clone()); + let duplicate = Arc::try_unwrap(candidate) + .unwrap_or_else(|_| unreachable!("fresh candidate has one owner")) + .log; + return CacheInsert::Cached { + active: existing, + retired: Some(duplicate), + }; + } + if entries.len() >= INSPECTION_CACHE_CAPACITY { + let Some(idle) = entries + .iter() + .position(|entry| Arc::strong_count(entry) == 1) + else { + return CacheInsert::Uncached(candidate); + }; + let evicted = Arc::try_unwrap(entries.remove(idle)) + .unwrap_or_else(|_| unreachable!("idle entry checked under lock")) + .log; + entries.push(candidate.clone()); + return CacheInsert::Cached { + active: candidate, + retired: Some(evicted), + }; + } + entries.push(candidate.clone()); + CacheInsert::Cached { + active: candidate, + retired: None, + } + } + + pub(in crate::root_store) fn request_shutdown(&self) { + for entry in self.entries.lock().unwrap().iter() { + entry.log.request_shutdown(); + } + } + + pub(in crate::root_store) fn take_all(&self) -> Vec> { + std::mem::take(&mut *self.entries.lock().unwrap()) + .into_iter() + .map(|entry| entry.log.clone()) + .collect() + } +} + +impl HistoryCheckpoints { + fn load_request(&self, load: LoadOperation) -> Result { + let request = LoadRequest { + scope: self.scope.clone(), + session_id: self.session.id().into(), + load, + }; + request.validate()?; + Ok(request) + } + + pub async fn load_exact(&self, load: LoadOperation) -> Result { + let request = self.load_request(load)?; + let gates = self.session.gates.clone(); + let log = self.session.scoped().await?; + worker(log, gates.clone(), move |log, objects| async move { + let _pin = gates + .write([gates::Key::CheckpointLoad(request.key()?)]) + .await?; + if let Some((prior, loaded)) = read(&log, request.key()?).await? { + if prior != request { + return Err(invalid("checkpoint load identity conflict")); + } + return match loaded.state { + LoadState::Preparing => resume(&log, &objects, &request, loaded, None).await, + _ => Ok(loaded), + }; + } + let selected = checkout( + &log, + &objects, + request.scope.clone(), + request.session_id.clone(), + request.load.revision, + ) + .await?; + // Durable request and pinned material precede any destination writes. + // Recheck Head at the claim's linearization point. + let loaded = claim(&log, request.clone(), selected.head).await?; + boundary("load_prepared"); + resume(&log, &objects, &request, loaded, Some(selected.manifest)).await + }) + .await + } + + pub async fn load_receipt( + &self, + load: LoadOperation, + ) -> Result, StoreError> { + let request = self.load_request(load)?; + read(&self.session.scoped().await?, request.key()?) + .await? + .map(|(prior, value)| { + if prior != request { + return Err(invalid("checkpoint load identity conflict")); + } + Ok(value) + }) + .transpose() + } + + pub async fn loaded_history( + &self, + operation: Uuid, + session: String, + position: Position, + thinking: bool, + ) -> Result { + self.with_loaded(operation, session.clone(), move |log, through| async move { + maka_agent_ports::session::history::Read { + session_id: session.clone(), + through: Some(through), + position, + include_thinking: thinking, + } + .validate() + .map_err(|error| invalid(error.to_string()))?; + log.history_text( + &session, + through, + position, + thinking, + crate::transcript::HistoryLimits { + max_bytes: 32 * 1024, + max_chunks: Some(8), + max_json_bytes: 64 * 1024, + }, + ) + .await + }) + .await + } + + pub async fn loaded_artifacts( + &self, + operation: Uuid, + session: String, + offset: u64, + ) -> Result { + self.with_loaded(operation, session.clone(), move |log, _| async move { + log.list_artifacts(&session, offset, 32).await + }) + .await + } + + /// Stream through one read-only inspection connection, with bounded chunks. + pub async fn stream_loaded_artifact( + &self, + operation: Uuid, + session: String, + artifact: String, + offset: u64, + mut emit: F, + ) -> Result<(), StoreError> + where + F: FnMut( + Option<( + maka_agent_facts::artifact::Artifact, + u64, + crate::artifacts::ArtifactChunk, + )>, + ) -> Fut + + Send + + 'static, + Fut: Future> + Send + 'static, + { + self.with_loaded(operation, session.clone(), move |log, _| async move { + let Some(record) = log.get_artifact(&session, &artifact).await?.record else { + return emit(None).await; + }; + let mut offset = offset; + loop { + let chunk = log + .read_artifact_chunk(&session, &artifact, offset, 32 * 1024) + .await? + .ok_or_else(|| invalid("inspection artifact payload missing"))?; + let next = offset + chunk.bytes.len() as u64; + let done = next >= chunk.total_bytes; + if !done && next == offset { + return Err(invalid("inspection artifact made no progress")); + } + emit(Some((record.clone(), offset, chunk))).await?; + if done { + return Ok(()); + } + offset = next; + } + }) + .await + } + + async fn with_loaded( + &self, + operation: Uuid, + session: String, + action: F, + ) -> Result + where + T: Send + 'static, + F: FnOnce(Arc, u64) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + let key = load_key(&self.scope, self.session.id(), operation)?; + let log = self.session.scoped().await?; + let gates = self.session.gates.clone(); + let work = self.session.control_dependency()?; + let pin = gates + .read([gates::Key::CheckpointLoad(key.clone())]) + .await?; + if let Some(entry) = self.inspections.get(&key) { + log.owned_root()?.validate_current()?; + if !entry.session_ids.contains(&session) { + return Err(StoreError::SessionNotFound); + } + let scoped = Arc::new(entry.log.scoped(Arc::new((log, work, pin, entry.clone())))); + return action(scoped, entry.through).await; + } + drop((work, pin)); + let inspections = self.inspections.clone(); + let session_store = self.session.clone(); + read_worker(log, gates.clone(), move |log, objects| async move { + let pin = gates + .read([gates::Key::CheckpointLoad(key.clone())]) + .await?; + if let Some(entry) = inspections.get(&key) { + if !entry.session_ids.contains(&session) { + return Err(StoreError::SessionNotFound); + } + let work = session_store.control_dependency()?; + let scoped = Arc::new(entry.log.scoped(Arc::new((log, work, pin, entry.clone())))); + return action(scoped, entry.through).await; + } + let (request, loaded) = read(&log, key.clone()) + .await? + .ok_or_else(|| invalid("checkpoint inspection missing"))?; + let LoadState::Ready { + ref session_ids, + through, + ref transfer_digest, + .. + } = loaded.state + else { + return Err(invalid("checkpoint inspection is not ready")); + }; + if !session_ids.contains(&session) { + return Err(StoreError::SessionNotFound); + } + let path = objects.inspection(&request.key()?, false)?; + let target = Arc::new( + EventLog::open_checkpoint_inspection(&path, objects.owner.clone(), through).await?, + ); + let verified = async { + let receipt = target + .bundle_import_receipt(transfer_digest, &request.binding()?) + .await? + .ok_or_else(|| invalid("checkpoint inspection import receipt missing"))?; + if receipt.root_session_id != request.session_id + || &receipt.session_ids != session_ids + { + return Err(invalid("checkpoint inspection inventory differs")); + } + Ok(()) + } + .await; + if let Err(error) = verified { + return close(target, Err(error)).await; + } + let candidate = Arc::new(CachedInspection { + key, + log: target, + through, + session_ids: session_ids.clone(), + }); + match inspections.insert(candidate) { + CacheInsert::Cached { active, retired } => { + if let Some(retired) = retired { + close(retired, Ok(())).await?; + } + let work = session_store.control_dependency()?; + let scoped = Arc::new(active.log.scoped(Arc::new(( + log, + work, + pin, + active.clone(), + )))); + action(scoped, active.through).await + } + CacheInsert::Uncached(entry) => { + let scoped = Arc::new(entry.log.scoped(Arc::new((log, pin)))); + let result = action(scoped, entry.through).await; + close(entry.log.clone(), result).await + } + } + }) + .await + } +} + +impl RootStore { + pub(in crate::root_store) async fn recover_checkpoint_loads(&self) -> Result<(), StoreError> { + for partition in &self.partitions { + let log = partition.scoped(partition.clone()); + let pending = log.connection.read(|db| Box::pin(async move { + Ok(sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM history_checkpoint_loads WHERE json_extract(result_json,'$.state.kind')='preparing')").fetch_one(db).await?) + })).await?; + if !pending { + continue; + } + worker(log, self.gates.clone(), move |log, objects| async move { + let mut after = String::new(); + loop { + let cursor = after.clone(); + let pending: Vec = log.connection.read(move |db| Box::pin(async move { + Ok(sqlx::query_scalar("SELECT load_key FROM history_checkpoint_loads WHERE json_extract(result_json,'$.state.kind')='preparing' AND load_key>? ORDER BY load_key LIMIT 32").bind(cursor).fetch_all(db).await?) + })).await?; + if pending.is_empty() { + break; + } + for key in pending { + after = key.clone(); + let (request, loaded) = read(&log, key) + .await? + .ok_or_else(|| invalid("checkpoint load disappeared"))?; + if let Err(error) = + resume(&log, &objects, &request, loaded.clone(), None).await + { + // Missing/corrupt material does not authorize recapture or block normal startup. + // SQL/IO uncertainty keeps Preparing for a later original-material retry. + let permanently_unavailable = match &error { + StoreError::InvalidTransition(_) + | StoreError::SessionConflict + | StoreError::EventConflict + | StoreError::ArtifactConflict => true, + StoreError::Io(io) => io.kind() == std::io::ErrorKind::NotFound, + _ => false, + }; + if permanently_unavailable { + write( + &log, + &request, + LoadedCheckpoint { + state: LoadState::Unavailable, + ..loaded + }, + ) + .await?; + } else { + eprintln!("Checkpoint inspection recovery remains pending: {error}"); + } + } + } + } + Ok(()) + }) + .await?; + } + Ok(()) + } +} + +async fn read( + log: &EventLog, + key: String, +) -> Result, StoreError> { + log.connection.read(move |db| Box::pin(async move { + let row: Option<(String, String)> = sqlx::query_as("SELECT request_json,result_json FROM history_checkpoint_loads WHERE load_key=?").bind(key).fetch_optional(db).await?; + row.map(|(request, result)| Ok((serde_json::from_str(&request)?, serde_json::from_str(&result)?))).transpose() + })).await +} +pub(super) async fn claim( + log: &EventLog, + request: LoadRequest, + head: Head, +) -> Result { + log.connection + .run(move |db| { + Box::pin(async move { + let mut tx = db.begin_with("BEGIN IMMEDIATE").await?; + let current: Option = sqlx::query_scalar( + "SELECT head_json FROM history_checkpoint_heads WHERE session_id=?", + ) + .bind(&request.session_id) + .fetch_optional(&mut *tx) + .await?; + if current + .map(|v| serde_json::from_str::(&v)) + .transpose()? + .as_ref() + != Some(&head) + { + return Err(invalid("history checkpoint revision unavailable")); + } + let value = LoadedCheckpoint { + head, + state: LoadState::Preparing, + }; + sqlx::query("INSERT INTO history_checkpoint_loads VALUES(?,?,?)") + .bind(request.key()?) + .bind(serde_json::to_string(&request)?) + .bind(serde_json::to_string(&value)?) + .execute(&mut *tx) + .await?; + tx.commit().await.map_err(StoreError::CommitUnknown)?; + Ok(value) + }) + }) + .await +} +async fn write( + log: &EventLog, + request: &LoadRequest, + value: LoadedCheckpoint, +) -> Result { + let key = request.key()?; + log.connection + .run(move |db| { + Box::pin(async move { + let result = sqlx::query( + "UPDATE history_checkpoint_loads SET result_json=? WHERE load_key=?", + ) + .bind(serde_json::to_string(&value)?) + .bind(key) + .execute(db) + .await?; + if result.rows_affected() != 1 { + return Err(invalid("checkpoint load receipt missing")); + } + Ok(value) + }) + }) + .await +} + +async fn close(target: Arc, result: Result) -> Result { + if let Err(error) = target.shutdown().await { + // Keep the physical worker, Root custody and per-inspection gate until SQL ends. + target.drain().await; + return Err(error); + } + result +} +fn manifest( + objects: &Objects, + request: &LoadRequest, + loaded: &LoadedCheckpoint, +) -> Result { + let value = objects.manifest(&loaded.head.manifest)?; + let Manifest { + root_id, + scope, + inventory, + .. + } = &value; + if loaded.head.revision != request.load.revision + || root_id != objects.root_id() + || scope != &request.scope + || inventory.root_session_id != request.session_id + { + return Err(invalid("checkpoint inspection origin differs")); + } + Ok(value) +} +async fn verify_receipt( + target: &EventLog, + objects: &Objects, + request: &LoadRequest, + loaded: &LoadedCheckpoint, +) -> Result<(), StoreError> { + let Manifest { + transfer_digest, + inventory, + .. + } = manifest(objects, request, loaded)?; + let receipt = target + .bundle_import_receipt(&transfer_digest, &request.binding()?) + .await? + .ok_or_else(|| invalid("checkpoint inspection import receipt missing"))?; + if receipt.root_session_id != inventory.root_session_id + || receipt.session_ids + != inventory + .sessions + .iter() + .map(|s| s.id.clone()) + .collect::>() + { + return Err(invalid("checkpoint inspection inventory differs")); + } + Ok(()) +} +async fn resume( + log: &EventLog, + objects: &Objects, + request: &LoadRequest, + loaded: LoadedCheckpoint, + verified_manifest: Option, +) -> Result { + let source = match verified_manifest { + Some(source) => source, + None => { + let source = manifest(objects, request, &loaded)?; + if let Some(snapshot) = source.workspace() { + super::workspace::verify(objects, snapshot)?; + } + source + } + }; + let workspace = source.workspace().cloned(); + let Manifest { + inventory, + transfer_digest, + .. + } = &source; + let inspection_path = objects.inspection(&request.key()?, true)?; + let target = Arc::new( + EventLog::open_owned( + &inspection_path, + Some(objects.owner.clone()), + OpenPurpose::Execution, + ) + .await?, + ); + let result = async { + if target.bundle_import_receipt(transfer_digest, &request.binding()?).await?.is_none() { + // An unclaimed nonempty database is never adopted or overwritten. + let nonempty = target.connection.read(|db| Box::pin(async move { + Ok(sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM session_control) OR EXISTS(SELECT 1 FROM event_log)").fetch_one(db).await?) + })).await?; + if nonempty { + return Err(invalid("checkpoint inspection destination is occupied")); + } + let file = objects.read(source.bundle())?; + let mut staged = StagedBundle::read(tokio::fs::File::from_std(file)) + .await.map_err(bundle_error)?; + let checked = async { + staged.validate_history().await.map_err(bundle_error)?; + if staged.summary().inventory != *inventory + || staged.summary().digest != *transfer_digest + { + return Err(invalid("checkpoint inspection bundle differs from manifest")); + } + Ok(()) + }.await; + if let Err(error) = checked { + staged.close().await.map_err(bundle_error)?; + return Err(error); + } + let configurations = inventory.sessions.iter() + .map(|session| (session.id.clone(), serde_json::json!({"kind":"checkpoint_inspection"}))) + .collect::>(); + target.import_bundle(staged, &request.binding()?, configurations) + .await.map_err(bundle_error)?; + } + verify_receipt(&target, objects, request, &loaded).await?; + Ok(LoadState::Ready { + session_ids: inventory.sessions.iter().map(|s| s.id.clone()).collect(), + through: target.committed_sequence().await?, + transfer_digest: transfer_digest.clone(), + workspace, + }) + }.await; + let state = close(target, result).await?; + std::fs::File::open(&inspection_path)?.sync_all()?; + std::fs::File::open( + inspection_path + .parent() + .ok_or_else(|| invalid("inspection directory missing"))?, + )? + .sync_all()?; + boundary("load_imported"); + let value = write(log, request, LoadedCheckpoint { state, ..loaded }).await?; + boundary("load_recorded"); + Ok(value) +} diff --git a/platform/event-log/src/root_store/checkpoints/objects.rs b/platform/event-log/src/root_store/checkpoints/objects.rs index 96c6a7a1b5c..f250927f03f 100644 --- a/platform/event-log/src/root_store/checkpoints/objects.rs +++ b/platform/event-log/src/root_store/checkpoints/objects.rs @@ -27,7 +27,7 @@ use std::{ /// Runs only on the checkpoint's dedicated physical worker, never a Tokio executor thread. pub(super) struct Objects { - owner: Arc, + pub(super) owner: Arc, directory: PathBuf, } impl Objects { @@ -47,6 +47,17 @@ impl Objects { File::open(owner.canonical_path())?.sync_all()?; Ok(Self { owner, directory }) } + pub(super) fn existing(owner: Arc) -> Result { + if !cfg!(unix) { + return Err(invalid( + "history checkpoint file durability is not supported on this platform", + )); + } + let directory = owner.canonical_path().join("history-checkpoints"); + let objects = Self { owner, directory }; + objects.validate()?; + Ok(objects) + } fn validate(&self) -> Result<(), StoreError> { self.owner.validate_current()?; for path in [ @@ -84,6 +95,37 @@ impl Objects { self.validate()?; Ok(self.directory.join("objects").join(&reference.digest)) } + pub(super) fn has(&self, reference: &ObjectRef) -> Result { + let path = self.path(reference)?; + match open_regular(&path) { + Ok(mut file) => { + if measure(&mut file)? != *reference { + return Err(invalid("checkpoint object digest or size differs")); + } + Ok(true) + } + Err(StoreError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } + } + pub(super) fn link_staged(&self, path: &Path, reference: &ObjectRef) -> Result<(), StoreError> { + let destination = self.path(reference)?; + match fs::hard_link(path, destination) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + self.read(reference)?; + } + Err(error) => return Err(error.into()), + } + self.owner.validate_current()?; + Ok(()) + } + pub(super) fn sync_objects(&self) -> Result<(), StoreError> { + self.validate()?; + File::open(self.directory.join("objects"))?.sync_all()?; + self.owner.validate_current()?; + Ok(()) + } pub(super) fn read(&self, reference: &ObjectRef) -> Result { let path = self.path(reference)?; let mut file = open_regular(&path)?; @@ -127,7 +169,7 @@ impl Objects { Ok(serde_json::from_reader(file)?) } pub(super) fn cleanup(&self, request: &Request) -> Result<(), StoreError> { - for kind in ["bundle", "manifest"] { + for kind in ["bundle", "manifest", "workspace", "workspace-file"] { let path = self.staging(request, kind)?; match fs::symlink_metadata(&path) { Ok(metadata) if metadata.file_type().is_file() => fs::remove_file(path)?, @@ -143,6 +185,39 @@ impl Objects { File::open(self.directory.join("staging"))?.sync_all()?; Ok(()) } + pub(super) fn inspection(&self, key: &str, create: bool) -> Result { + self.validate()?; + if !crate::checkpoints::valid_digest(key) { + return Err(invalid("invalid inspection key")); + } + let directory = self.directory.join("inspections"); + if create { + root::private_directory(&directory)?; + } + if !fs::symlink_metadata(&directory)?.file_type().is_dir() { + return Err(invalid("inspection directory is not a directory")); + } + let path = directory.join(format!("{key}.sqlite")); + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_file() => {} + Err(error) if create && error.kind() == std::io::ErrorKind::NotFound => { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let file = options.open(&path)?; + file.sync_all()?; + File::open(&directory)?.sync_all()?; + File::open(&self.directory)?.sync_all()?; + } + Err(error) => return Err(error.into()), + _ => return Err(invalid("inspection database is not a regular file")), + } + Ok(path) + } pub(super) fn root_id(&self) -> &str { self.owner.root_id() } diff --git a/platform/event-log/src/root_store/checkpoints/restore.rs b/platform/event-log/src/root_store/checkpoints/restore.rs new file mode 100644 index 00000000000..fb38bce444a --- /dev/null +++ b/platform/event-log/src/root_store/checkpoints/restore.rs @@ -0,0 +1,467 @@ +/* + * 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. + */ + +//! Same-Root workspace recovery; the live history must still equal the captured cut. +use super::*; +use crate::checkpoints::{PreparedRestore, RestoreOperation, RestoredSession}; +use maka_agent_facts::execution::DirectoryIdentity; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::SqliteConnection; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct RequestIdentity { + scope: Scope, + session: String, + operation: RestoreOperation, +} +impl RequestIdentity { + fn key(&self) -> Result { + if !crate::checkpoints::valid_digest(&self.operation.fingerprint) + || !self.operation.parent.is_absolute() + { + return Err(invalid("invalid checkpoint restore request")); + } + crate::checkpoints::load_key(&self.scope, &self.session, self.operation.operation) + } +} +struct Preparation { + prepared: PreparedRestore, + directory_identity: Option, + result: Option, +} +impl Preparation { + fn directory_identity(&self) -> Result<&DirectoryIdentity, StoreError> { + self.directory_identity + .as_ref() + .ok_or_else(|| invalid("checkpoint restore directory preparation incomplete")) + } +} +async fn prior( + log: &EventLog, + request: &RequestIdentity, +) -> Result, StoreError> { + let key = request.key()?; + let expected = serde_json::to_string(request)?; + log.connection.read(move |db| Box::pin(async move { + let row: Option<(String, String, Option, Option)> = sqlx::query_as("SELECT request_json,prepared_json,directory_identity_json,result_json FROM checkpoint_workspace_restores WHERE restore_key=?").bind(key).fetch_optional(db).await?; + row.map(|(original, prepared, directory_identity, result)| { + if original != expected { return Err(invalid("checkpoint restore identity conflict")); } + Ok(Preparation { + prepared: serde_json::from_str(&prepared)?, + directory_identity: directory_identity.map(|v|serde_json::from_str(&v)).transpose()?, + result: result.map(|r|serde_json::from_str(&r)).transpose()?, + }) + }).transpose() + })).await +} + +pub(crate) struct Commit { + key: String, + request: RequestIdentity, + prepared: PreparedRestore, + directory_identity: DirectoryIdentity, +} +impl Commit { + pub(crate) async fn validate(&self, db: &mut SqliteConnection) -> Result<(), StoreError> { + let current: Option = + sqlx::query_scalar("SELECT head_json FROM history_checkpoint_heads WHERE session_id=?") + .bind(&self.request.session) + .fetch_optional(&mut *db) + .await?; + if current + .as_deref() + .map(serde_json::from_str::) + .transpose()? + .as_ref() + != Some(&self.prepared.head) + { + return Err(invalid("checkpoint restore head moved")); + } + let archived: bool = sqlx::query_scalar("SELECT archived FROM session_control WHERE id=?") + .bind(&self.request.session) + .fetch_optional(&mut *db) + .await? + .ok_or(StoreError::SessionNotFound)?; + if archived { + return Err(invalid("archived Session cannot be restored")); + } + let inventory = crate::bundle::Inventory { + root_session_id: self.request.session.clone(), + subtree_digest: String::new(), + sessions: vec![crate::bundle::Session { + id: self.request.session.clone(), + revision: 0, + }], + }; + crate::bundle::require_idle(db, &inventory).await?; + if crate::bundle::history_cut(db, &self.request.session).await? != self.prepared.history_cut + { + return Err(invalid("live Session history advanced beyond checkpoint")); + } + let row: Option<(String, String, Option, Option)> = sqlx::query_as("SELECT request_json,prepared_json,directory_identity_json,result_json FROM checkpoint_workspace_restores WHERE restore_key=?").bind(&self.key).fetch_optional(db).await?; + if row + != Some(( + serde_json::to_string(&self.request)?, + serde_json::to_string(&self.prepared)?, + Some(serde_json::to_string(&self.directory_identity)?), + None, + )) + { + return Err(invalid("checkpoint restore claim changed")); + } + Ok(()) + } + pub(crate) async fn record( + self, + db: &mut SqliteConnection, + revision: u64, + ) -> Result<(), StoreError> { + let result = RestoredSession { + operation: self.request.operation.operation, + session_id: self.request.session, + session_revision: revision, + prepared: self.prepared, + }; + sqlx::query("UPDATE checkpoint_workspace_restores SET result_json=? WHERE restore_key=? AND result_json IS NULL").bind(serde_json::to_string(&result)?).bind(self.key).execute(db).await?; + Ok(()) + } +} +impl HistoryCheckpoints { + fn restore_identity(&self, operation: RestoreOperation) -> RequestIdentity { + RequestIdentity { + scope: self.scope.clone(), + session: self.session.id().into(), + operation, + } + } + pub async fn workspace_restore_receipt( + &self, + operation: uuid::Uuid, + fingerprint: String, + revision: Revision, + ) -> Result, StoreError> { + let key = crate::checkpoints::load_key(&self.scope, self.session.id(), operation)?; + let scope = self.scope.clone(); + let session = self.session.id().to_owned(); + self.session.scoped().await?.connection.read(move |db|Box::pin(async move { + let row: Option<(String,Option)> = sqlx::query_as("SELECT request_json,result_json FROM checkpoint_workspace_restores WHERE restore_key=?").bind(key).fetch_optional(db).await?; + row.map(|(request,result)| { + let request: RequestIdentity = serde_json::from_str(&request)?; + if request.scope != scope || request.session != session || request.operation.operation != operation || request.operation.fingerprint != fingerprint || request.operation.revision != revision { return Err(invalid("checkpoint restore identity conflict")); } + result.map(|r|serde_json::from_str(&r).map_err(StoreError::from)).transpose() + }).transpose().map(Option::flatten) + })).await + } + /// The parent was selected by the trusted Host. Always creates a new child, + /// never overwrites an existing project. Partial destinations fail closed. + pub async fn prepare_workspace_restore( + &self, + operation: RestoreOperation, + ) -> Result { + let request = self.restore_identity(operation); + request.key()?; + let log = self.session.exclusive_metadata(true).await?; + worker(log, self.session.gates.clone(), move |log, objects| async move { + if let Some(prior) = prior(&log, &request).await? { + if prior.result.is_none() { verify_destination(&objects, &prior.prepared, &request.operation, prior.directory_identity()?)?; } + return Ok(prior.prepared); + } + let checkout = checkout(&log, &objects, request.scope.clone(), request.session.clone(), request.operation.revision).await?; + let Manifest { inventory, workspace, history_cut, .. } = checkout.manifest; + let workspace = workspace.ok_or_else(|| invalid("checkpoint has no workspace"))?; + if inventory.sessions.len() != 1 || inventory.sessions[0].id != request.session { return Err(invalid("checkpoint restore requires one ordinary Session")); } + let current = log.get_session::(&request.session).await?.ok_or(StoreError::SessionNotFound)?; + if current.archived { return Err(invalid("archived Session cannot be restored")); } + let expected = history_cut.clone(); + log.connection.read(move |db| Box::pin(async move { + crate::bundle::require_idle(db, &inventory).await?; + if crate::bundle::history_cut(db, &inventory.root_session_id).await? != expected { + return Err(invalid("live Session history advanced beyond checkpoint")); + } + Ok(()) + })).await?; + let parent = request.operation.parent.canonicalize()?; + let directory = cap_std::fs::Dir::open_ambient_dir(&parent, cap_std::ambient_authority())?; + if directory_identity(&directory)? != request.operation.parent_identity { return Err(invalid("restore parent directory changed")); } + if parent.starts_with(objects.owner.canonical_path()) { return Err(invalid("restore destination overlaps private Root")); } + let prepared = PreparedRestore { head: checkout.head, history_cut, workspace, path: parent.join(format!("maka-restored-{}", request.key()?)) }; + let key = request.key()?; + let encoded = serde_json::to_string(&request)?; + let data = serde_json::to_string(&prepared)?; + // The durable claim precedes writes, and commit rechecks both Head and live revision. + log.connection.run(move |db|Box::pin(async move { + sqlx::query("INSERT INTO checkpoint_workspace_restores(restore_key,request_json,prepared_json) VALUES(?,?,?)").bind(key).bind(encoded).bind(data).execute(db).await?; Ok(()) + })).await?; + let name = prepared.path.file_name().ok_or_else(||invalid("restore destination missing"))?; + directory.create_dir(name)?; + use cap_fs_ext::DirExt; + let destination = directory.open_dir_nofollow(name)?; + let identity = directory_identity(&destination)?; + workspace::materialize_in(&objects, &prepared.workspace, &destination)?; + directory.open(".")?.sync_all()?; + let key = request.key()?; + let identity = serde_json::to_string(&identity)?; + // A claim alone never proves we created the directory. Publish its + // physical identity only after the complete copy and directory sync. + log.connection.run(move |db|Box::pin(async move { + let changed = sqlx::query("UPDATE checkpoint_workspace_restores SET directory_identity_json=? WHERE restore_key=? AND directory_identity_json IS NULL AND result_json IS NULL").bind(identity).bind(key).execute(db).await?; + if changed.rows_affected() != 1 { return Err(invalid("checkpoint restore claim changed")); } + Ok(()) + })).await?; + Ok(prepared) + }).await + } + /// Configuration and the original restore receipt commit atomically. This + /// rebinding never rewinds history or starts an invocation. + pub async fn commit_workspace_restore( + &self, + operation: RestoreOperation, + now: u64, + mut update: F, + ) -> Result + where + T: Serialize + serde::de::DeserializeOwned + Clone + PartialEq + Send + 'static, + F: FnMut(&mut T) -> Result<(), StoreError> + Send + 'static, + { + crate::sessions::validate_time(now)?; + let request = self.restore_identity(operation); + let log = self.session.exclusive_metadata(true).await?; + let gates = self.session.gates.clone(); + let session_store = self.session.clone(); + let control = self + .session + .control + .upgrade() + .ok_or(StoreError::ConnectionClosed)?; + worker(log, gates.clone(), move |log, objects| async move { + let preparation = prior(&log, &request) + .await? + .ok_or_else(|| invalid("checkpoint restore not prepared"))?; + if let Some(result) = preparation.result { + return Ok(result); + } + let directory_identity = preparation.directory_identity()?.clone(); + let prepared = preparation.prepared; + verify_destination(&objects, &prepared, &request.operation, &directory_identity)?; + loop { + let record = log + .get_session::(&request.session) + .await? + .ok_or(StoreError::SessionNotFound)?; + let mut normalized = serde_json::to_value(&record.configuration)?; + crate::workspace_bindings::normalize( + &mut normalized, + record.workspace_binding.as_ref(), + )?; + let mut candidate: T = serde_json::from_value(normalized)?; + update(&mut candidate)?; + let encoded = serde_json::to_value(&candidate)?; + let reference = crate::workspace_bindings::attached(&encoded)? + .ok_or_else(|| invalid("restore workspace binding missing"))?; + let pin = gates.read([gates::Key::Binding(reference.id)]).await?; + let binding = control + .workspace_binding::(reference.id) + .await? + .ok_or_else(|| invalid("restore binding missing"))?; + if binding.workspace.get("path").and_then(Value::as_str) != prepared.path.to_str() { + return Err(invalid("restore binding differs from prepared workspace")); + } + let control_dependency = session_store.control_dependency()?; + let key = request.key()?; + let result = log + .scoped(Arc::new((pin, control_dependency))) + .mutate_session_metadata::( + &request.session, + record.revision, + true, + Some(now), + Some(control.clone()), + Some(Commit { + key, + request: request.clone(), + prepared: prepared.clone(), + directory_identity: directory_identity.clone(), + }), + move |value| { + *value = candidate; + Ok(()) + }, + ) + .await?; + match result { + crate::sessions::SessionMutation::Committed(_) => break, + crate::sessions::SessionMutation::RevisionConflict { .. } => continue, + } + } + prior(&log, &request) + .await? + .and_then(|v| v.result) + .ok_or_else(|| invalid("checkpoint restore receipt missing")) + }) + .await + } +} +fn verify_destination( + objects: &Objects, + prepared: &PreparedRestore, + operation: &RestoreOperation, + identity: &DirectoryIdentity, +) -> Result<(), StoreError> { + use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt}; + use cap_std::fs::{Dir, OpenOptions}; + use sha2::{Digest, Sha256}; + use std::io::Read; + let parent = Dir::open_ambient_dir(&operation.parent, cap_std::ambient_authority())?; + if directory_identity(&parent)? != operation.parent_identity { + return Err(invalid("restore parent directory changed")); + } + let root = parent.open_dir_nofollow( + prepared + .path + .file_name() + .ok_or_else(|| invalid("restore destination missing"))?, + )?; + if &directory_identity(&root)? != identity { + return Err(invalid("restore destination directory changed")); + } + let entries = workspace::inventory(objects, &prepared.workspace)?; + let expected: std::collections::BTreeSet<_> = entries + .iter() + .map(|entry| match entry { + crate::checkpoints::WorkspaceEntry::Directory { path } + | crate::checkpoints::WorkspaceEntry::File { path, .. } => path.clone(), + }) + .collect(); + fn paths( + dir: &Dir, + prefix: &str, + expected: &std::collections::BTreeSet, + result: &mut std::collections::BTreeSet, + ) -> Result<(), StoreError> { + for entry in dir.entries()? { + let entry = entry?; + let name = entry + .file_name() + .into_string() + .map_err(|_| invalid("restored path invalid"))?; + if prefix.is_empty() && name == ".maka-workspace.json" { + continue; + } + let path = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if !expected.contains(&path) { + return Err(invalid("restored workspace inventory changed")); + } + result.insert(path.clone()); + if entry.file_type()?.is_dir() { + paths(&dir.open_dir_nofollow(&name)?, &path, expected, result)?; + } + } + Ok(()) + } + let mut actual = std::collections::BTreeSet::new(); + paths(&root, "", &expected, &mut actual)?; + if actual != expected { + return Err(invalid("restored workspace inventory changed")); + } + for entry in entries { + let path = match &entry { + crate::checkpoints::WorkspaceEntry::Directory { path } + | crate::checkpoints::WorkspaceEntry::File { path, .. } => path, + }; + let mut parts = path.split('/').collect::>(); + let name = parts.pop().unwrap(); + let mut parent = root.try_clone()?; + for part in parts { + parent = parent.open_dir_nofollow(part)?; + } + match &entry { + crate::checkpoints::WorkspaceEntry::Directory { .. } => { + parent.open_dir_nofollow(name)?; + } + crate::checkpoints::WorkspaceEntry::File { + object, executable, .. + } => { + let mut options = OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No); + #[cfg(unix)] + { + use cap_std::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK); + } + let mut file = parent.open_with(name, &options)?; + if !file.metadata()?.is_file() { + return Err(invalid("restored workspace entry changed")); + } + #[cfg(unix)] + { + use cap_std::fs::MetadataExt; + if (file.metadata()?.mode() & 0o111 != 0) != *executable { + return Err(invalid("restored executable bit changed")); + } + } + #[cfg(not(unix))] + let _ = executable; + let mut hash = Sha256::new(); + let mut bytes = 0u64; + let mut buffer = [0u8; 65536]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + bytes += count as u64; + if bytes > object.bytes { + return Err(invalid("restored workspace changed")); + } + hash.update(&buffer[..count]); + } + if bytes != object.bytes || hex::encode(hash.finalize()) != object.digest { + return Err(invalid("restored workspace changed")); + } + } + } + } + Ok(()) +} + +pub(super) fn directory_identity( + directory: &cap_std::fs::Dir, +) -> Result { + use cap_fs_ext::MetadataExt; + let metadata = directory.dir_metadata()?; + let birth = metadata + .created() + .ok() + .and_then(|time| time.into_std().duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| (d.as_secs(), d.subsec_nanos())); + maka_agent_facts::execution::DirectoryIdentity::try_from( + maka_agent_facts::artifact::content_digest(&serde_json::to_vec(&( + metadata.dev(), + metadata.ino(), + birth, + ))?), + ) + .map_err(invalid) +} diff --git a/platform/event-log/src/root_store/checkpoints/tests.rs b/platform/event-log/src/root_store/checkpoints/tests.rs index 40d94ec1f5f..4ca7021c833 100644 --- a/platform/event-log/src/root_store/checkpoints/tests.rs +++ b/platform/event-log/src/root_store/checkpoints/tests.rs @@ -26,6 +26,8 @@ use std::{ use uuid::Uuid; mod body; +mod load; +mod workspace; fn namespaces(path: &Path) -> root::RootNamespaces { root::RootNamespaces { @@ -60,6 +62,7 @@ async fn fixture(path: &Path) -> (RootStore, HistoryCheckpoints, PublicationRequ fingerprint: "a".repeat(64), expected_revision: None, confirmed_subtree_digest: store.preview_bundle("source").await.unwrap().subtree_digest, + workspace: None, }; (store, handle, request) } @@ -710,6 +713,7 @@ async fn killed_writer_recovers_without_a_new_capture() { fingerprint: "a".repeat(64), expected_revision: None, confirmed_subtree_digest: store.preview_bundle("source").await.unwrap().subtree_digest, + workspace: None, }; let receipt = handle.receipt(input.clone()).await.unwrap().unwrap(); if matches!(phase, "claimed" | "payload") { diff --git a/platform/event-log/src/root_store/checkpoints/tests/body.rs b/platform/event-log/src/root_store/checkpoints/tests/body.rs index d741f934eb0..9237a26484d 100644 --- a/platform/event-log/src/root_store/checkpoints/tests/body.rs +++ b/platform/event-log/src/root_store/checkpoints/tests/body.rs @@ -42,8 +42,71 @@ async fn checkpoint_keeps_tool_bytes_after_source_collection_and_import() { let plain = |fact| EventWrite::plain(RuntimeEvent::new(invocation.clone(), fact)).unwrap(); log.append(&plain(Fact::InvocationOpened { configuration: None, - input: InvocationInput::Code { - source: "fixture".into(), + input: InvocationInput::Message { + content: "read the large tool result".into(), + source_messages: vec![], + request_fingerprint: None, + }, + })) + .await + .unwrap(); + use maka_agent_facts::model::{ + ModelFinishReason, ModelPart, ModelToolCall, ModelUsage, + recorded::{CompletionPart, ModelCompletion, ModelPartRecord}, + }; + let context = log + .read_model_view("source", Some(&invocation.invocation_id)) + .await + .unwrap(); + log.append(&plain(Fact::ModelRequested { + source_capture: maka_agent_facts::event::SourceCapture { + publication_watermark: context.selection.captured_through, + prior_unknown: context.selection.prior_unknown, + }, + context_breakdown: None, + item_acceptance: false, + purpose: maka_agent_facts::context::ModelPurpose::Main, + context: None, + checkpoint_event_id: None, + step_id: "step".into(), + model_id: "model".into(), + source_scope: context.source_evidence.scope, + source_high_water: context.source_evidence.high_water, + source_digest: context.source_evidence.digest, + effective_source_digest: Some(context.effective_source_digest), + input_digest: "fixture".into(), + route_identity: "route".into(), + })) + .await + .unwrap(); + log.append(&plain(Fact::ModelCompleted { + step_id: "step".into(), + output: ModelCompletion { + compaction: None, + parts: vec![CompletionPart::Own(ModelPartRecord { + ordinal: 0, + message_id: maka_agent_facts::tool_call::tool_use_id( + &invocation.invocation_id, + "step:call", + ), + started_at: std::time::UNIX_EPOCH, + provider_part_id: None, + part: Arc::new(ModelPart::ToolCall { + call: ModelToolCall { + id: "call".into(), + name: "Read".into(), + input: json!({}).into(), + provider_options: None, + provider_executed: false, + }, + }), + })], + finish_reason: ModelFinishReason::ToolCalls, + usage: ModelUsage::default(), + provider_options: None, + response_id: None, + model: None, + timestamp: None, }, })) .await @@ -51,8 +114,8 @@ async fn checkpoint_keeps_tool_bytes_after_source_collection_and_import() { log.append(&plain(Fact::ToolDispatched { presentation: None, title: None, - operation_id: "read".into(), - call: ToolCallIdentity::standalone("call".into()), + operation_id: "step:call".into(), + call: ToolCallIdentity::provider("step".into(), "call".into()), name: "Read".into(), input: json!({}), })) @@ -63,7 +126,7 @@ async fn checkpoint_keeps_tool_bytes_after_source_collection_and_import() { "result".into(), std::time::UNIX_EPOCH, invocation.clone(), - "read".into(), + "step:call".into(), ToolSuccess::projected( ToolOutput::Text(text.clone()), DurableToolProjection::Text { text: text.clone() }, @@ -130,6 +193,36 @@ async fn checkpoint_keeps_tool_bytes_after_source_collection_and_import() { let store = RootStore::open(owner(directory.path())).await.unwrap(); let handle = store.history_checkpoints("source", scope()).await.unwrap(); assert_eq!(handle.receipt(request).await.unwrap(), Some(value)); + // The public inspection path must page tool bodies after canonical source GC. + let load = crate::checkpoints::LoadOperation { + operation: Uuid::new_v4(), + fingerprint: "c".repeat(64), + revision: head.revision, + }; + handle.load_exact(load.clone()).await.unwrap(); + use maka_agent_ports::session::history::{Page, Position, Role}; + let mut position = Position::Start; + let mut restored_tool = String::new(); + let mut pages = 0; + loop { + let Page::Ready { chunks, after, .. } = handle + .loaded_history(load.operation, "source".into(), position, false) + .await + .unwrap(); + pages += 1; + for chunk in chunks { + if chunk.role == Role::ToolResult { + restored_tool.push_str(&chunk.text); + } + } + let Some(cursor) = after else { + break; + }; + position = Position::After { cursor }; + assert!(pages < 100, "paging must advance"); + } + assert!(pages > 1); + assert_eq!(restored_tool, text); let checkout = handle.checkout_exact(head.revision).await.unwrap(); let mut staged = StagedBundle::read(tokio::fs::File::from_std(checkout.bundle)) .await diff --git a/platform/event-log/src/root_store/checkpoints/tests/load.rs b/platform/event-log/src/root_store/checkpoints/tests/load.rs new file mode 100644 index 00000000000..477345fc0b7 --- /dev/null +++ b/platform/event-log/src/root_store/checkpoints/tests/load.rs @@ -0,0 +1,420 @@ +/* + * 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. + */ + +use super::*; +use crate::checkpoints::{LoadOperation, LoadRequest, LoadState}; +use maka_agent_ports::session::history::Position; + +fn request(revision: Revision) -> LoadOperation { + LoadOperation { + operation: Uuid::from_u128(101), + fingerprint: "b".repeat(64), + revision, + } +} +#[tokio::test] +async fn exact_load_is_idempotent_isolated_and_survives_head_move() { + let dir = tempfile::tempdir().unwrap(); + let (store, handle, publish) = fixture(dir.path()).await; + let head = published(&handle.publish(publish.clone()).await.unwrap()); + let input = request(head.revision); + let (a, b) = tokio::join!( + handle.load_exact(input.clone()), + handle.load_exact(input.clone()) + ); + let value = a.unwrap(); + assert_eq!(b.unwrap(), value); + assert!(matches!(value.state, LoadState::Ready { .. })); + let source = store + .get_session::("source") + .await + .unwrap() + .unwrap(); + assert_eq!(source.configuration["title"], "original"); + assert_eq!(source.revision, 1); + let mut next = publish; + next.operation = Uuid::from_u128(102); + next.expected_revision = Some(head.revision); + let newer = published(&handle.publish(next).await.unwrap()); + let mut conflict = input.clone(); + conflict.revision = newer.revision; + assert!( + handle + .load_exact(conflict) + .await + .unwrap_err() + .to_string() + .contains("identity conflict") + ); + let mut stale = input.clone(); + stale.operation = Uuid::from_u128(103); + assert!(handle.load_exact(stale.clone()).await.is_err()); + assert!(handle.load_receipt(stale).await.unwrap().is_none()); + assert_eq!(handle.load_exact(input.clone()).await.unwrap(), value); + store.close().await.unwrap(); + let store = RootStore::open(owner(dir.path())).await.unwrap(); + let handle = store.history_checkpoints("source", scope()).await.unwrap(); + assert_eq!( + handle.load_receipt(input.clone()).await.unwrap(), + Some(value) + ); + handle + .loaded_history(input.operation, "source".into(), Position::Tail, false) + .await + .unwrap(); + assert!(matches!( + handle + .loaded_history(input.operation, "foreign".into(), Position::Tail, false) + .await, + Err(StoreError::SessionNotFound) + )); + let mut foreign = scope(); + foreign.deployment = Uuid::new_v4(); + let other = store.history_checkpoints("source", foreign).await.unwrap(); + assert!( + other + .loaded_history(input.operation, "source".into(), Position::Tail, false) + .await + .is_err() + ); + store.close().await.unwrap(); + assert!(handle.load_receipt(input).await.is_err()); +} + +#[tokio::test] +async fn load_reconciles_committed_import_when_receipt_write_failed() { + let dir = tempfile::tempdir().unwrap(); + let (store, handle, publish) = fixture(dir.path()).await; + let head = published(&handle.publish(publish.clone()).await.unwrap()); + let input = request(head.revision); + let log = handle.session.scoped().await.unwrap(); + log.connection.run(|db|Box::pin(async move { + sqlx::raw_sql("CREATE TRIGGER fail_load_receipt BEFORE UPDATE ON history_checkpoint_loads BEGIN SELECT RAISE(ABORT,'lost receipt'); END;").execute(db).await?; Ok(()) + })).await.unwrap(); + assert!( + handle + .load_exact(input.clone()) + .await + .unwrap_err() + .to_string() + .contains("lost receipt") + ); + assert_eq!( + handle + .load_receipt(input.clone()) + .await + .unwrap() + .unwrap() + .state, + LoadState::Preparing + ); + log.connection + .run(|db| { + Box::pin(async move { + sqlx::query("DROP TRIGGER fail_load_receipt") + .execute(db) + .await?; + Ok(()) + }) + }) + .await + .unwrap(); + drop(log); + let mut newer = publish; + newer.operation = Uuid::new_v4(); + newer.expected_revision = Some(head.revision); + handle.publish(newer).await.unwrap(); + store.close().await.unwrap(); + let store = RootStore::open(owner(dir.path())).await.unwrap(); + let handle = store.history_checkpoints("source", scope()).await.unwrap(); + let result = handle.load_receipt(input.clone()).await.unwrap().unwrap(); + assert_eq!(result.head, head); + assert!(matches!(result.state, LoadState::Ready { .. })); + assert_eq!(handle.load_exact(input).await.unwrap(), result); + store.close().await.unwrap(); +} + +#[tokio::test] +async fn load_rejects_corruption_and_never_adopts_occupied_destination() { + let dir = tempfile::tempdir().unwrap(); + let (store, handle, publish) = fixture(dir.path()).await; + let head = published(&handle.publish(publish).await.unwrap()); + let input = request(head.revision); + let record = LoadRequest { + scope: scope(), + session_id: "source".into(), + load: input.clone(), + }; + let objects = Objects::open(store.owned_root().unwrap()).unwrap(); + let path = objects.inspection(&record.key().unwrap(), true).unwrap(); + let occupied = EventLog::open(&path).await.unwrap(); + occupied + .create_session("intruder", "fixture", &json!({}), 1) + .await + .unwrap(); + occupied.close().await.unwrap(); + assert!( + handle + .load_exact(input.clone()) + .await + .unwrap_err() + .to_string() + .contains("occupied") + ); + let occupied = EventLog::open(&path).await.unwrap(); + assert!( + occupied + .get_session::("intruder") + .await + .unwrap() + .is_some() + ); + assert!( + occupied + .get_session::("source") + .await + .unwrap() + .is_none() + ); + occupied.close().await.unwrap(); + let mut corrupt = input.clone(); + corrupt.operation = Uuid::new_v4(); + let manifest = objects.manifest(&head.manifest).unwrap(); + std::fs::write( + dir.path() + .join("root/history-checkpoints/objects") + .join(&manifest.bundle().digest), + b"corrupt", + ) + .unwrap(); + assert!(handle.load_exact(corrupt.clone()).await.is_err()); + assert!(handle.load_receipt(corrupt).await.unwrap().is_none()); + drop(objects); + store.close().await.unwrap(); +} + +#[test] +fn load_crash_child() { + let Some(path) = std::env::var_os("MAKA_LOAD_TEST_ROOT") else { + return; + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let (store, handle, publish) = fixture(Path::new(&path)).await; + let head = published(&handle.publish(publish).await.unwrap()); + handle.load_exact(request(head.revision)).await.unwrap(); + store.close().await.unwrap(); + }); +} + +#[tokio::test] +async fn load_process_death_recovers_original_target_at_each_publication_boundary() { + for phase in ["load_prepared", "load_imported", "load_recorded"] { + let dir = tempfile::tempdir().unwrap(); + let ready = dir.path().join("ready"); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "root_store::checkpoints::tests::load::load_crash_child", + "--nocapture", + ]) + .env("MAKA_LOAD_TEST_ROOT", dir.path()) + .env("MAKA_CHECKPOINT_TEST_PHASE", phase) + .env("MAKA_CHECKPOINT_TEST_READY", &ready) + .stdout(std::process::Stdio::null()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(30); + while !ready.exists() { + if let Some(exit) = child.try_wait().unwrap() { + panic!("load child exited: {exit}"); + } + if Instant::now() > deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("load child timeout {phase}"); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + child.kill().unwrap(); + child.wait().unwrap(); + let store = RootStore::open(owner(dir.path())).await.unwrap(); + let handle = store.history_checkpoints("source", scope()).await.unwrap(); + let head = handle.read_head().await.unwrap().unwrap(); + let input = request(head.revision); + let receipt = handle.load_receipt(input.clone()).await.unwrap().unwrap(); + assert!( + matches!(receipt.state, LoadState::Ready { .. }), + "{phase}: {receipt:?}" + ); + assert_eq!(handle.load_exact(input).await.unwrap(), receipt); + store.close().await.unwrap(); + } +} + +#[tokio::test] +async fn inspection_reads_share_admission_and_do_not_take_a_writer_lease() { + let dir = tempfile::tempdir().unwrap(); + let (store, handle, publish) = fixture(dir.path()).await; + let head = published(&handle.publish(publish).await.unwrap()); + let input = request(head.revision); + handle.load_exact(input.clone()).await.unwrap(); + let key = LoadRequest { + scope: scope(), + session_id: "source".into(), + load: input.clone(), + } + .key() + .unwrap(); + let objects = Objects::existing(store.owned_root().unwrap()).unwrap(); + let database = objects.inspection(&key, false).unwrap(); + let mut lock = database.as_os_str().to_os_string(); + lock.push(".writer.lock"); + let lease = crate::root::FileLease::acquire(Path::new(&lock)).unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let reader = handle.clone(); + let (e, r) = (entered.clone(), release.clone()); + let task = tokio::spawn(async move { + reader + .stream_loaded_artifact( + input.operation, + "source".into(), + "missing".into(), + 0, + move |value| { + let (e, r) = (e.clone(), r.clone()); + async move { + assert!(value.is_none()); + e.notify_one(); + r.notified().await; + Ok(()) + } + }, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(10), entered.notified()) + .await + .unwrap(); + let read = tokio::time::timeout( + Duration::from_secs(10), + handle.loaded_history(input.operation, "source".into(), Position::Tail, false), + ) + .await; + release.notify_one(); + task.await.unwrap().unwrap(); + read.unwrap().unwrap(); + drop(lease); + store.close().await.unwrap(); +} + +#[tokio::test] +async fn stream_loaded_artifact_handles_multichunk_payloads_and_missing_material_settles_unavailable() + { + use maka_agent_facts::artifact::{Artifact, ArtifactKind, ArtifactSource, content_digest}; + let dir = tempfile::tempdir().unwrap(); + let (store, handle, mut publish) = fixture(dir.path()).await; + let payload: Vec = (0..(96 * 1024)).map(|index| (index % 251) as u8).collect(); + let log = handle.session.scoped().await.unwrap(); + log.commit_artifact( + Artifact { + id: "report-bin".into(), + session_id: "source".into(), + turn_id: "turn-1".into(), + created_at: 1, + name: "report.bin".into(), + kind: ArtifactKind::File, + size_bytes: payload.len() as u64, + mime_type: Some("application/octet-stream".into()), + source: ArtifactSource::UserUpload, + summary: Some(content_digest(&payload)), + }, + payload.clone(), + ) + .await + .unwrap(); + drop(log); + publish.confirmed_subtree_digest = store.preview_bundle("source").await.unwrap().subtree_digest; + let head = published(&handle.publish(publish.clone()).await.unwrap()); + let input = request(head.revision); + handle.load_exact(input.clone()).await.unwrap(); + let mut chunks = Vec::new(); + let mut reconstructed = Vec::new(); + handle + .stream_loaded_artifact( + input.operation, + "source".into(), + "report-bin".into(), + 0, + move |item| { + let (_, offset, chunk) = item.expect("artifact must exist"); + assert_eq!(offset, reconstructed.len() as u64); + chunks.push(chunk.bytes.len()); + reconstructed.extend_from_slice(&chunk.bytes); + let done = reconstructed.len() as u64 == chunk.total_bytes; + if done { + assert_eq!(chunks, vec![32 * 1024, 32 * 1024, 32 * 1024]); + assert_eq!(reconstructed, payload); + } + async { Ok(()) } + }, + ) + .await + .unwrap(); + + // Missing immutable material during Preparing recovery settles to LoadState::Unavailable. + let mut missing_op = input.clone(); + missing_op.operation = Uuid::new_v4(); + let missing_req = LoadRequest { + scope: scope(), + session_id: "source".into(), + load: missing_op.clone(), + }; + let log = handle.session.scoped().await.unwrap(); + super::super::load::claim(&log, missing_req, head.clone()) + .await + .unwrap(); + drop(log); + let objects = Objects::open(store.owned_root().unwrap()).unwrap(); + let manifest = objects.manifest(&head.manifest).unwrap(); + std::fs::remove_file( + dir.path() + .join("root/history-checkpoints/objects") + .join(&manifest.bundle().digest), + ) + .unwrap(); + drop(objects); + store.close().await.unwrap(); + let store = RootStore::open(owner(dir.path())).await.unwrap(); + let handle = store.history_checkpoints("source", scope()).await.unwrap(); + assert_eq!( + handle + .load_receipt(missing_op) + .await + .unwrap() + .unwrap() + .state, + LoadState::Unavailable + ); + store.close().await.unwrap(); +} diff --git a/platform/event-log/src/root_store/checkpoints/tests/workspace.rs b/platform/event-log/src/root_store/checkpoints/tests/workspace.rs new file mode 100644 index 00000000000..cbf9cf749ad --- /dev/null +++ b/platform/event-log/src/root_store/checkpoints/tests/workspace.rs @@ -0,0 +1,824 @@ +/* + * 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. + */ + +use super::*; +use crate::checkpoints::{ + LoadOperation, LoadRequest, LoadState, WorkspaceCapture, WorkspaceConsistency, WorkspaceEntry, +}; + +#[tokio::test] +async fn workspace_material_is_immutable_and_load_preserves_conditional_guarantee() { + for mode in [ + WorkspaceConsistency::CallerConfirmedQuiescent, + WorkspaceConsistency::Unverified, + ] { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("project"); + std::fs::create_dir_all(source.join("empty")).unwrap(); + std::fs::create_dir_all(source.join("src")).unwrap(); + std::fs::create_dir_all(source.join(".git")).unwrap(); + std::fs::write(source.join("src/main.rs"), "original").unwrap(); + std::fs::write(source.join(".git/config"), "not archived").unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + source.join("src/main.rs"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + let (store, handle, mut request) = fixture(dir.path()).await; + request.workspace = Some(WorkspaceCapture { + root: source.clone(), + identity: "workspace-id".into(), + consistency: mode, + }); + let head = published(&handle.publish(request.clone()).await.unwrap()); + std::fs::write(source.join("src/main.rs"), "later version").unwrap(); + let checkout = handle.checkout_exact(head.revision).await.unwrap(); + assert_eq!(checkout.manifest.workspace().unwrap().consistency, mode); + let operation = LoadOperation { + operation: Uuid::new_v4(), + fingerprint: "b".repeat(64), + revision: head.revision, + }; + let loaded = handle.load_exact(operation.clone()).await.unwrap(); + let LoadState::Ready { + workspace: Some(snapshot), + .. + } = loaded.state + else { + panic!("workspace absent") + }; + assert_eq!(snapshot.consistency, mode); + let key = LoadRequest { + scope: scope(), + session_id: "source".into(), + load: operation.clone(), + } + .key() + .unwrap(); + let path = store + .owned_root() + .unwrap() + .canonical_path() + .join("history-checkpoints/inspections") + .join(format!("{key}.workspace")); + assert!( + !path.exists(), + "inspection must not copy workspace payloads" + ); + assert_eq!(published(&handle.publish(request).await.unwrap()), head); + store.close().await.unwrap(); + let store = RootStore::open(owner(dir.path())).await.unwrap(); + let handle = store.history_checkpoints("source", scope()).await.unwrap(); + assert!(matches!( + handle.load_receipt(operation).await.unwrap().unwrap().state, + LoadState::Ready { + workspace: Some(_), + .. + } + )); + store.close().await.unwrap(); + } +} + +#[tokio::test] +async fn unsafe_workspace_material_cannot_publish_a_head() { + for kind in ["symlink", "fifo", "overlap"] { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("project"); + std::fs::create_dir(&source).unwrap(); + let (store, handle, mut request) = fixture(dir.path()).await; + match kind { + "symlink" => std::os::unix::fs::symlink(dir.path(), source.join("link")).unwrap(), + "fifo" => { + assert!( + std::process::Command::new("mkfifo") + .arg(source.join("pipe")) + .status() + .unwrap() + .success() + ); + } + _ => {} + } + request.workspace = Some(WorkspaceCapture { + root: if kind == "overlap" { + dir.path().to_owned() + } else { + source + }, + identity: "workspace-id".into(), + consistency: WorkspaceConsistency::Unverified, + }); + assert!( + tokio::time::timeout(Duration::from_secs(10), handle.publish(request)) + .await + .unwrap() + .is_err() + ); + assert!(handle.read_head().await.unwrap().is_none()); + store.close().await.unwrap(); + } +} + +#[tokio::test] +async fn corrupt_workspace_object_prevents_exact_load() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("project"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("file"), "original").unwrap(); + let (store, handle, mut request) = fixture(dir.path()).await; + request.workspace = Some(WorkspaceCapture { + root: source, + identity: "workspace-id".into(), + consistency: WorkspaceConsistency::Unverified, + }); + let head = published(&handle.publish(request).await.unwrap()); + let checkout = handle.checkout_exact(head.revision).await.unwrap(); + let objects = Objects::open(store.owned_root().unwrap()).unwrap(); + let inventory: serde_json::Value = serde_json::from_reader( + objects + .read(&checkout.manifest.workspace().unwrap().inventory) + .unwrap(), + ) + .unwrap(); + let entries: Vec = + serde_json::from_value(inventory["entries"].clone()).unwrap(); + let WorkspaceEntry::File { object, .. } = &entries[0] else { + panic!() + }; + std::fs::write( + store + .owned_root() + .unwrap() + .canonical_path() + .join("history-checkpoints/objects") + .join(&object.digest), + "corrupt", + ) + .unwrap(); + let request = LoadOperation { + operation: Uuid::new_v4(), + fingerprint: "b".repeat(64), + revision: head.revision, + }; + assert!(handle.load_exact(request.clone()).await.is_err()); + assert!(handle.load_receipt(request).await.unwrap().is_none()); + store.close().await.unwrap(); +} + +#[tokio::test] +async fn detected_external_writer_aborts_capture_even_when_caller_confirmed() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("project"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("file"), "before").unwrap(); + let (store, handle, publication) = fixture(dir.path()).await; + let objects = Objects::open(store.owned_root().unwrap()).unwrap(); + let changed = source.join("file"); + super::super::workspace::AFTER_SCAN.with(|hook| { + *hook.borrow_mut() = Some(Box::new(move || std::fs::write(changed, "after!").unwrap())) + }); + let request = handle.request(publication).unwrap(); + let error = super::super::workspace::capture( + &objects, + &request, + &WorkspaceCapture { + root: source, + identity: "workspace".into(), + consistency: WorkspaceConsistency::CallerConfirmedQuiescent, + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("changed")); + assert!(handle.read_head().await.unwrap().is_none()); + store.close().await.unwrap(); +} + +async fn restore_fixture( + path: &Path, +) -> ( + RootStore, + HistoryCheckpoints, + PublicationRequest, + crate::checkpoints::RestoreOperation, + crate::checkpoints::PreparedRestore, + serde_json::Value, +) { + let (store, handle, mut publish) = fixture(path).await; + let source = path.join("project"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("file"), "frozen").unwrap(); + let binding = store + .attach_workspace("fixture", &json!({"path":source})) + .await + .unwrap(); + assert!(matches!( + store + .update_session_metadata("source", 1, move |value: &mut serde_json::Value| { + value["workspace"] = serde_json::to_value(binding)?; + value["boundary_revision"] = json!(1); + Ok(()) + }) + .await + .unwrap(), + crate::sessions::SessionMutation::Committed(_) + )); + { + use maka_agent_facts::{ + event::{ + EventWrite, Fact, Invocation, InvocationInput, InvocationOutcome, RuntimeEvent, + }, + input::{DeliveredMessage, MessageInput}, + message::{MessageDisposition, Placement, RootSourceMessage}, + }; + let log = handle.session.scoped().await.unwrap(); + let content: MessageInput = "initial message".to_owned().into(); + let message = RootSourceMessage { + message: DeliveredMessage { + origin: None, + message_id: "msg-read-1".into(), + content: content.clone(), + submitted_content_digest: content.content_digest().unwrap(), + }, + unprepared_content: content.clone(), + submitted_placement: Placement::CurrentTurn, + disposition: MessageDisposition::TurnStarted, + submitted_intent: None, + }; + let invocation = Invocation { + session_id: "source".into(), + turn_id: "turn-1".into(), + run_id: "run-1".into(), + invocation_id: "inv-1".into(), + }; + log.append( + &EventWrite::plain(RuntimeEvent::new( + invocation.clone(), + Fact::InvocationOpened { + configuration: None, + input: InvocationInput::Message { + content, + source_messages: vec![message], + request_fingerprint: None, + }, + }, + )) + .unwrap(), + ) + .await + .unwrap(); + log.append( + &EventWrite::plain(RuntimeEvent::new( + invocation, + Fact::InvocationEnded { + outcome: InvocationOutcome::Completed, + }, + )) + .unwrap(), + ) + .await + .unwrap(); + } + publish.confirmed_subtree_digest = store.preview_bundle("source").await.unwrap().subtree_digest; + publish.workspace = Some(WorkspaceCapture { + root: source, + identity: "workspace".into(), + consistency: WorkspaceConsistency::Unverified, + }); + let head = published(&handle.publish(publish.clone()).await.unwrap()); + let parent = path.join("destinations"); + std::fs::create_dir(&parent).unwrap(); + let directory = + cap_std::fs::Dir::open_ambient_dir(&parent, cap_std::ambient_authority()).unwrap(); + let operation = crate::checkpoints::RestoreOperation { + operation: Uuid::new_v4(), + fingerprint: "d".repeat(64), + revision: head.revision, + parent, + parent_identity: super::super::restore::directory_identity(&directory).unwrap(), + }; + let prepared = handle + .prepare_workspace_restore(operation.clone()) + .await + .unwrap(); + let binding = store + .attach_workspace("fixture", &json!({"path":prepared.path})) + .await + .unwrap(); + let configuration = json!({"title":"restored","workspace":binding,"boundary_revision":2}); + (store, handle, publish, operation, prepared, configuration) +} +async fn commit_restore( + handle: &HistoryCheckpoints, + operation: crate::checkpoints::RestoreOperation, + configuration: &serde_json::Value, + now: u64, +) -> Result { + let configuration = configuration.clone(); + handle + .commit_workspace_restore(operation, now, move |value: &mut serde_json::Value| { + *value = configuration.clone(); + Ok(()) + }) + .await +} +#[tokio::test] +async fn restore_receipt_is_atomic_with_binding_and_survives_later_source_changes() { + let directory = tempfile::tempdir().unwrap(); + let (store, _handle, _, operation, prepared, configuration) = + restore_fixture(directory.path()).await; + store.close().await.unwrap(); + let store = RootStore::open(owner(directory.path())).await.unwrap(); + let handle = store.history_checkpoints("source", scope()).await.unwrap(); + assert_eq!( + handle + .prepare_workspace_restore(operation.clone()) + .await + .unwrap(), + prepared + ); + let before = store + .get_session::("source") + .await + .unwrap() + .unwrap(); + let log = handle.session.scoped().await.unwrap(); + log.connection.run(|db|Box::pin(async move {sqlx::raw_sql("CREATE TRIGGER reject_restore BEFORE UPDATE ON checkpoint_workspace_restores BEGIN SELECT RAISE(ABORT,'reject receipt'); END;").execute(db).await?;Ok(())})).await.unwrap(); + drop(log); + assert!( + commit_restore(&handle, operation.clone(), &configuration, 2) + .await + .is_err() + ); + let unchanged = store + .get_session::("source") + .await + .unwrap() + .unwrap(); + assert_eq!(unchanged.revision, before.revision); + assert_eq!(unchanged.configuration, before.configuration); + let log = handle.session.scoped().await.unwrap(); + log.connection + .run(|db| { + Box::pin(async move { + sqlx::query("DROP TRIGGER reject_restore") + .execute(db) + .await?; + Ok(()) + }) + }) + .await + .unwrap(); + drop(log); + let committed = commit_restore(&handle, operation.clone(), &configuration, 3) + .await + .unwrap(); + assert_eq!(committed.prepared, prepared); + assert_eq!( + commit_restore(&handle, operation.clone(), &configuration, 3) + .await + .unwrap(), + committed + ); + store + .update_session_metadata( + "source", + committed.session_revision, + |value: &mut serde_json::Value| { + value["title"] = json!("later"); + Ok(()) + }, + ) + .await + .unwrap(); + std::fs::rename(&operation.parent, directory.path().join("moved-target")).unwrap(); + assert_eq!( + handle + .workspace_restore_receipt( + operation.operation, + operation.fingerprint.clone(), + operation.revision + ) + .await + .unwrap(), + Some(committed.clone()) + ); + store.close().await.unwrap(); + let store = RootStore::open(owner(directory.path())).await.unwrap(); + let handle = store.history_checkpoints("source", scope()).await.unwrap(); + assert_eq!( + handle + .workspace_restore_receipt( + operation.operation, + operation.fingerprint, + operation.revision + ) + .await + .unwrap(), + Some(committed) + ); + store.close().await.unwrap(); +} +#[tokio::test] +async fn restore_rejects_head_race_and_changed_destination_without_rebinding() { + for race in [ + "head", + "history", + "busy", + "files", + "extra", + "parent", + "symlink", + "replacement", + ] { + let directory = tempfile::tempdir().unwrap(); + let (store, handle, mut publish, operation, prepared, configuration) = + restore_fixture(directory.path()).await; + match race { + "history" | "busy" => { + use maka_agent_facts::event::{ + EventWrite, Fact, Invocation, InvocationInput, InvocationOutcome, RuntimeEvent, + }; + let log = handle.session.scoped().await.unwrap(); + let invocation = Invocation { + session_id: "source".into(), + turn_id: "new".into(), + run_id: "new".into(), + invocation_id: "new".into(), + }; + log.append( + &EventWrite::plain(RuntimeEvent::new( + invocation.clone(), + Fact::InvocationOpened { + configuration: None, + input: InvocationInput::Message { + content: "new history".into(), + source_messages: vec![], + request_fingerprint: None, + }, + }, + )) + .unwrap(), + ) + .await + .unwrap(); + if race == "history" { + log.append( + &EventWrite::plain(RuntimeEvent::new( + invocation, + Fact::InvocationEnded { + outcome: InvocationOutcome::Completed, + }, + )) + .unwrap(), + ) + .await + .unwrap(); + } + } + "head" => { + publish.operation = Uuid::new_v4(); + publish.expected_revision = Some(operation.revision); + handle.publish(publish).await.unwrap(); + } + "files" => std::fs::write(prepared.path.join("file"), "modified").unwrap(), + "parent" => { + std::fs::rename(&operation.parent, directory.path().join("old-parent")).unwrap(); + std::fs::create_dir(&operation.parent).unwrap(); + } + "symlink" => { + let moved = operation.parent.join("moved-workspace"); + std::fs::rename(&prepared.path, &moved).unwrap(); + std::os::unix::fs::symlink(moved, &prepared.path).unwrap(); + } + "replacement" => { + std::fs::rename(&prepared.path, operation.parent.join("original-output")).unwrap(); + std::fs::create_dir(&prepared.path).unwrap(); + std::fs::write(prepared.path.join("file"), "frozen").unwrap(); + } + _ => std::fs::write(prepared.path.join("extra"), "unrecorded").unwrap(), + } + let before = store + .get_session::("source") + .await + .unwrap() + .unwrap(); + assert!( + commit_restore(&handle, operation.clone(), &configuration, 2) + .await + .is_err(), + "accepted {race} race" + ); + let after = store + .get_session::("source") + .await + .unwrap() + .unwrap(); + assert_eq!(after.revision, before.revision); + assert_eq!(after.configuration, before.configuration); + assert!( + handle + .workspace_restore_receipt( + operation.operation, + operation.fingerprint, + operation.revision + ) + .await + .unwrap() + .is_none() + ); + store.close().await.unwrap(); + } +} + +#[tokio::test] +async fn restore_retry_never_adopts_an_occupied_destination() { + let directory = tempfile::tempdir().unwrap(); + let (store, handle, _, mut operation, _, _) = restore_fixture(directory.path()).await; + operation.operation = Uuid::new_v4(); + let key = crate::checkpoints::load_key(&scope(), "source", operation.operation).unwrap(); + let occupied = operation.parent.join(format!("maka-restored-{key}")); + std::fs::create_dir(&occupied).unwrap(); + std::fs::write(occupied.join("file"), "frozen").unwrap(); + assert!( + handle + .prepare_workspace_restore(operation.clone()) + .await + .is_err() + ); + store.close().await.unwrap(); + let store = RootStore::open(owner(directory.path())).await.unwrap(); + let handle = store.history_checkpoints("source", scope()).await.unwrap(); + assert!( + handle.prepare_workspace_restore(operation).await.is_err(), + "retry adopted a directory it did not create" + ); + assert_eq!( + std::fs::read_to_string(occupied.join("file")).unwrap(), + "frozen" + ); + store.close().await.unwrap(); +} + +#[tokio::test] +async fn restore_cannot_commit_a_copy_without_a_durable_completion_record() { + let directory = tempfile::tempdir().unwrap(); + let (store, handle, _, mut operation, _, _) = restore_fixture(directory.path()).await; + operation.operation = Uuid::new_v4(); + let log = handle.session.scoped().await.unwrap(); + log.connection.run(|db|Box::pin(async move { + sqlx::raw_sql("CREATE TRIGGER reject_preparation BEFORE UPDATE OF directory_identity_json ON checkpoint_workspace_restores BEGIN SELECT RAISE(ABORT,'copy completion not recorded'); END;").execute(db).await?; + Ok(()) + })).await.unwrap(); + drop(log); + assert!( + handle + .prepare_workspace_restore(operation.clone()) + .await + .is_err() + ); + let key = crate::checkpoints::load_key(&scope(), "source", operation.operation).unwrap(); + let path = operation.parent.join(format!("maka-restored-{key}")); + assert_eq!( + std::fs::read_to_string(path.join("file")).unwrap(), + "frozen" + ); + let binding = store + .attach_workspace("fixture", &json!({"path":path})) + .await + .unwrap(); + let configuration = json!({"workspace":binding,"boundary_revision":2}); + assert!( + commit_restore(&handle, operation, &configuration, 2) + .await + .is_err() + ); + store.close().await.unwrap(); +} + +#[tokio::test] +async fn workspace_ignores_build_outputs_and_secrets_but_keeps_certificate_fixtures() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("project"); + for child in [ + ".git/info", + ".github/workflows", + "target", + ".venv", + "nested", + "fixtures", + ] { + std::fs::create_dir_all(source.join(child)).unwrap(); + } + std::fs::write( + source.join(".gitignore"), + "target/\n.venv/\n*.log\n!keep.log\n", + ) + .unwrap(); + std::fs::write(source.join(".git/info/exclude"), "local-only\n").unwrap(); + std::fs::write(source.join("nested/.gitignore"), "ignored\n").unwrap(); + for file in [ + "target/output", + ".venv/bin", + "build.log", + "local-only", + "nested/ignored", + ".env", + ".env.local", + ".env.example", + ".github/workflows/ci.yml", + "fixtures/test.pem", + "fixtures/test.key", + "keep.log", + "nested/keep", + ] { + std::fs::write(source.join(file), file).unwrap(); + } + std::os::unix::fs::symlink("/nonexistent", source.join("target/link")).unwrap(); + let (store, handle, mut request) = fixture(dir.path()).await; + request.workspace = Some(WorkspaceCapture { + root: source, + identity: "workspace".into(), + consistency: WorkspaceConsistency::Unverified, + }); + let head = published(&handle.publish(request).await.unwrap()); + let checkout = handle.checkout_exact(head.revision).await.unwrap(); + let objects = Objects::open(store.owned_root().unwrap()).unwrap(); + let inventory: serde_json::Value = serde_json::from_reader( + objects + .read(&checkout.manifest.workspace().unwrap().inventory) + .unwrap(), + ) + .unwrap(); + let paths = inventory["entries"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["path"].as_str().unwrap()) + .collect::>(); + for retained in [ + ".env.example", + ".github/workflows/ci.yml", + "fixtures/test.pem", + "fixtures/test.key", + "keep.log", + "nested/keep", + ] { + assert!(paths.contains(retained), "missing {retained}: {paths:?}"); + } + for omitted in [ + "target", + ".venv", + "build.log", + "local-only", + "nested/ignored", + ".env", + ".env.local", + ".git", + ] { + assert!(!paths.contains(omitted), "unexpected {omitted}: {paths:?}"); + } + assert!(inventory.get("version").is_none()); + store.close().await.unwrap(); +} + +#[tokio::test] +async fn restore_allows_metadata_changes_and_repeated_restore_of_the_same_history() { + let directory = tempfile::tempdir().unwrap(); + let (store, handle, _, original, _, mut configuration) = + restore_fixture(directory.path()).await; + let current = store + .get_session::("source") + .await + .unwrap() + .unwrap(); + store + .update_session_metadata( + "source", + current.revision, + |value: &mut serde_json::Value| { + value["title"] = json!("renamed after checkpoint"); + Ok(()) + }, + ) + .await + .unwrap(); + let mut request = original.clone(); + request.operation = Uuid::new_v4(); + request.fingerprint = "e".repeat(64); + let prepared = handle + .prepare_workspace_restore(request.clone()) + .await + .unwrap(); + assert_eq!( + std::fs::read_to_string(prepared.path.join("file")).unwrap(), + "frozen" + ); + configuration["workspace"] = serde_json::to_value( + store + .attach_workspace("fixture", &json!({"path":prepared.path})) + .await + .unwrap(), + ) + .unwrap(); + commit_restore(&handle, request, &configuration, 3) + .await + .unwrap(); + let mut again = original; + again.operation = Uuid::new_v4(); + again.fingerprint = "f".repeat(64); + let second = handle + .prepare_workspace_restore(again.clone()) + .await + .unwrap(); + assert_ne!(prepared.path, second.path); + // Non-history mutations (read marker + metadata update) between prepare and commit + // must be preserved rather than rejected or overwritten. + store + .set_session_read_marker::("source", "msg-read-1") + .await + .unwrap(); + let live = store + .get_session::("source") + .await + .unwrap() + .unwrap(); + store + .update_session_metadata("source", live.revision, |value: &mut serde_json::Value| { + value["title"] = json!("renamed between prepare and commit"); + Ok(()) + }) + .await + .unwrap(); + let new_binding = store + .attach_workspace("fixture", &json!({"path":second.path})) + .await + .unwrap(); + let restored = handle + .commit_workspace_restore(again, 4, move |value: &mut serde_json::Value| { + value["workspace"] = serde_json::to_value(new_binding)?; + value["boundary_revision"] = json!(3); + Ok(()) + }) + .await + .unwrap(); + let after = store + .get_session::("source") + .await + .unwrap() + .unwrap(); + assert_eq!(after.revision, restored.session_revision); + assert_eq!( + after.read_state.last_read_message_id.as_deref(), + Some("msg-read-1") + ); + assert_eq!( + after.configuration["title"], + "renamed between prepare and commit" + ); + store.close().await.unwrap(); +} + +#[tokio::test] +async fn case_colliding_paths_fail_before_publishing_a_head() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("project"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("Readme.md"), "upper").unwrap(); + let probe = source.join("readme.md"); + std::fs::write(&probe, "lower").unwrap(); + if std::fs::read_to_string(source.join("Readme.md")).unwrap() == "lower" { + // Case-insensitive host filesystem cannot create colliding siblings in the first place. + return; + } + let (store, handle, mut request) = fixture(dir.path()).await; + request.workspace = Some(WorkspaceCapture { + root: source, + identity: "workspace-id".into(), + consistency: WorkspaceConsistency::CallerConfirmedQuiescent, + }); + let outcome = handle.publish(request).await; + assert!( + outcome.is_err(), + "expected case-collision failure: {outcome:?}" + ); + assert!(handle.read_head().await.unwrap().is_none()); + store.close().await.unwrap(); +} diff --git a/platform/event-log/src/root_store/checkpoints/workspace.rs b/platform/event-log/src/root_store/checkpoints/workspace.rs new file mode 100644 index 00000000000..2b2535f30f0 --- /dev/null +++ b/platform/event-log/src/root_store/checkpoints/workspace.rs @@ -0,0 +1,432 @@ +/* + * 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. + */ + +//! Bounded directory capture. Caller attestations never become filesystem fencing. +use super::*; +use crate::checkpoints::{WorkspaceCapture, WorkspaceEntry, WorkspaceSnapshot}; +use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt}; +use cap_std::fs::{Dir, OpenOptions}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, BTreeSet}, + io::{Read, Write}, + path::Path, +}; + +#[cfg(test)] +thread_local! { pub(super) static AFTER_SCAN: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; } + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Inventory { + entries: Vec, +} + +fn relative(path: &str) -> Result<(), StoreError> { + if path.is_empty() + || path.len() > 4096 + || path.contains('\\') + || path.contains(':') + || path.chars().any(char::is_control) + || path + .split('/') + .any(|p| p.is_empty() || p == "." || p == ".." || p.ends_with(['.', ' '])) + { + return Err(invalid("nonportable checkpoint workspace path")); + } + Ok(()) +} + +fn excluded(name: &str) -> bool { + matches!( + name, + ".git" | "node_modules" | ".DS_Store" | "__pycache__" | ".cache" | ".maka-workspace.json" + ) +} + +fn secret(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + name == ".env" + || name.starts_with(".env.") + && !matches!( + name.as_str(), + ".env.example" | ".env.sample" | ".env.template" + ) + || matches!( + name.as_str(), + ".ssh" | ".aws" | ".gnupg" | "credentials.json" | "id_rsa" | "id_ed25519" + ) +} + +fn stamp(meta: &cap_std::fs::Metadata) -> Result { + #[cfg(unix)] + { + use cap_std::fs::MetadataExt; + Ok(format!( + "{}:{}:{}:{}:{}:{}:{:?}", + meta.dev(), + meta.ino(), + meta.len(), + meta.mode(), + meta.ctime(), + meta.ctime_nsec(), + meta.modified()? + )) + } + #[cfg(not(unix))] + { + Ok(format!( + "{}:{:?}:{:?}", + meta.len(), + meta.file_type(), + meta.modified()? + )) + } +} + +fn scan(dir: &Dir, root: &Path) -> Result, StoreError> { + // The repository controls what belongs to its workspace. The capability + // handle is still used to open every selected path without following links. + let walk = ignore::WalkBuilder::new(root) + .hidden(false) + .parents(false) + .git_global(false) + .require_git(false) + .follow_links(false) + .filter_entry(|entry| { + entry.depth() == 0 + || entry + .file_name() + .to_str() + .is_none_or(|name| !excluded(name) && !secret(name)) + }) + .build(); + let mut output = BTreeMap::new(); + let mut folded = BTreeSet::new(); + for entry in walk { + let entry = + entry.map_err(|error| invalid(format!("checkpoint workspace scan: {error}")))?; + if let Some(error) = entry.error() { + return Err(invalid(format!( + "checkpoint workspace ignore rules: {error}" + ))); + } + if entry.depth() == 0 { + continue; + } + let path = entry + .path() + .strip_prefix(root) + .map_err(|_| invalid("checkpoint workspace path escaped root"))? + .to_str() + .ok_or_else(|| invalid("non-UTF8 checkpoint workspace path"))?; + relative(path)?; + if !folded.insert(path.to_lowercase()) { + return Err(invalid("checkpoint workspace path collision")); + } + let (parent, name) = parent(dir, path)?; + let metadata = parent.symlink_metadata(&name)?; + if !metadata.is_dir() && !metadata.is_file() { + return Err(invalid( + "checkpoint workspace links and special files are unsupported", + )); + } + output.insert(path.to_owned(), stamp(&metadata)?); + } + Ok(output) +} + +fn parent(dir: &Dir, path: &str) -> Result<(Dir, String), StoreError> { + relative(path)?; + let mut parts = path.split('/').collect::>(); + let name = parts.pop().unwrap().to_owned(); + let mut current = dir.try_clone()?; + for part in parts { + current = current.open_dir_nofollow(part)?; + } + Ok((current, name)) +} + +pub(super) fn capture( + objects: &Objects, + request: &Request, + source: &WorkspaceCapture, +) -> Result { + if !source.root.is_absolute() || source.identity.is_empty() { + return Err(invalid("invalid checkpoint workspace binding")); + } + let source_path = std::fs::canonicalize(&source.root)?; + let root_path = objects.owner.canonical_path(); + if source_path.starts_with(root_path) || root_path.starts_with(&source_path) { + return Err(invalid("checkpoint workspace overlaps state root")); + } + let dir = Dir::open_ambient_dir(&source_path, cap_std::ambient_authority())?; + let before_root = stamp(&dir.dir_metadata()?)?; + let before = scan(&dir, &source_path)?; + #[cfg(test)] + AFTER_SCAN.with(|hook| { + if let Some(action) = hook.borrow_mut().take() { + action(); + } + }); + let mut entries = Vec::new(); + let mut linked = false; + for (path, expected) in &before { + objects.owner.validate_current()?; + let (parent, name) = parent(&dir, path)?; + let metadata = parent.symlink_metadata(&name)?; + if stamp(&metadata)? != *expected { + return Err(invalid("checkpoint workspace changed during capture")); + } + if metadata.is_dir() { + entries.push(WorkspaceEntry::Directory { path: path.clone() }); + continue; + } + let mut options = OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No); + #[cfg(unix)] + { + use cap_std::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK); + } + let mut file = parent.open_with(&name, &options)?; + if !file.metadata()?.is_file() || stamp(&file.metadata()?)? != *expected { + return Err(invalid("checkpoint workspace file changed")); + } + use sha2::{Digest, Sha256}; + let mut buffer = [0u8; 65536]; + let object = if metadata.len() <= buffer.len() as u64 { + let mut copied = 0usize; + loop { + let n = file.read(&mut buffer[copied..])?; + if n == 0 { + break; + } + copied += n; + if copied as u64 > metadata.len() || copied == buffer.len() { + if copied as u64 > metadata.len() { + return Err(invalid("checkpoint workspace size limit or source change")); + } + break; + } + } + let extra = if copied == buffer.len() { + let mut probe = [0u8; 1]; + file.read(&mut probe)? + } else { + 0 + }; + if extra != 0 + || copied as u64 != metadata.len() + || stamp(&file.metadata()?)? != *expected + || stamp(&parent.symlink_metadata(&name)?)? != *expected + { + return Err(invalid("checkpoint workspace changed during capture")); + } + let object = ObjectRef { + digest: hex::encode(Sha256::digest(&buffer[..copied])), + bytes: copied as u64, + }; + if !objects.has(&object)? { + let mut staged = objects.create(request, "workspace-file")?; + staged.write_all(&buffer[..copied])?; + staged.sync_all()?; + drop(staged); + let staging = objects.staging(request, "workspace-file")?; + objects.link_staged(&staging, &object)?; + std::fs::remove_file(staging)?; + linked = true; + } + object + } else { + let mut staged = objects.create(request, "workspace-file")?; + let mut hash = Sha256::new(); + let mut copied = 0u64; + loop { + let n = file.read(&mut buffer)?; + if n == 0 { + break; + } + copied += n as u64; + if copied > metadata.len() { + return Err(invalid("checkpoint workspace size limit or source change")); + } + hash.update(&buffer[..n]); + staged.write_all(&buffer[..n])?; + } + if copied != metadata.len() + || stamp(&file.metadata()?)? != *expected + || stamp(&parent.symlink_metadata(&name)?)? != *expected + { + return Err(invalid("checkpoint workspace changed during capture")); + } + let object = ObjectRef { + digest: hex::encode(hash.finalize()), + bytes: copied, + }; + let staging = objects.staging(request, "workspace-file")?; + if objects.has(&object)? { + drop(staged); + std::fs::remove_file(staging)?; + } else { + staged.sync_all()?; + drop(staged); + objects.link_staged(&staging, &object)?; + std::fs::remove_file(staging)?; + linked = true; + } + object + }; + #[cfg(unix)] + let executable = { + use cap_std::fs::MetadataExt; + metadata.mode() & 0o111 != 0 + }; + #[cfg(not(unix))] + let executable = false; + entries.push(WorkspaceEntry::File { + path: path.clone(), + object, + executable, + }); + } + let after = scan(&dir, &source_path)?; + let named = Dir::open_ambient_dir(&source.root, cap_std::ambient_authority())?; + if before != after + || before_root != stamp(&dir.dir_metadata()?)? + || before_root != stamp(&named.dir_metadata()?)? + { + return Err(invalid("checkpoint workspace changed during capture")); + } + if linked { + objects.sync_objects()?; + } + let encoded = serde_json::to_vec(&Inventory { entries })?; + let mut file = objects.create(request, "workspace")?; + file.write_all(&encoded)?; + file.sync_all()?; + drop(file); + Ok(WorkspaceSnapshot { + identity: source.identity.clone(), + consistency: source.consistency, + inventory: objects.publish(&objects.staging(request, "workspace")?)?, + }) +} + +pub(super) fn inventory( + objects: &Objects, + snapshot: &WorkspaceSnapshot, +) -> Result, StoreError> { + let inventory: Inventory = serde_json::from_reader(objects.read(&snapshot.inventory)?)?; + let mut paths = BTreeSet::new(); + let mut directories = BTreeSet::new(); + let mut total = 0u64; + for entry in &inventory.entries { + let path = match entry { + WorkspaceEntry::Directory { path } | WorkspaceEntry::File { path, .. } => path, + }; + relative(path)?; + // Reject case aliases even when the publishing filesystem happens to allow them. + if !paths.insert(path.to_lowercase()) { + return Err(invalid("checkpoint workspace path collision")); + } + if let Some((parent, _)) = path.rsplit_once('/') + && !directories.contains(parent) + { + return Err(invalid("checkpoint workspace parent missing")); + } + match entry { + WorkspaceEntry::Directory { path } => { + directories.insert(path.as_str()); + } + WorkspaceEntry::File { object, .. } => { + total = total + .checked_add(object.bytes) + .ok_or_else(|| invalid("checkpoint workspace size overflow"))?; + } + } + } + Ok(inventory.entries) +} + +pub(super) fn verify(objects: &Objects, snapshot: &WorkspaceSnapshot) -> Result<(), StoreError> { + for entry in inventory(objects, snapshot)? { + if let WorkspaceEntry::File { object, .. } = entry { + objects.read(&object)?; + } + } + Ok(()) +} + +/// The caller supplies an exclusively owned, empty destination. +pub(super) fn materialize_in( + objects: &Objects, + snapshot: &WorkspaceSnapshot, + dir: &Dir, +) -> Result<(), StoreError> { + let entries = inventory(objects, snapshot)?; + if dir.entries()?.next().is_some() { + return Err(invalid("checkpoint workspace destination is occupied")); + } + for entry in entries { + match entry { + WorkspaceEntry::Directory { path } => { + let (parent, name) = parent(dir, &path)?; + parent.create_dir(&name)?; + } + WorkspaceEntry::File { + path, + object, + executable, + } => { + let (parent, name) = parent(dir, &path)?; + let mut source = objects.read(&object)?; + let mut options = OpenOptions::new(); + options + .write(true) + .create_new(true) + .follow(FollowSymlinks::No); + #[cfg(unix)] + { + use cap_std::fs::OpenOptionsExt; + options.mode(if executable { 0o700 } else { 0o600 }); + } + #[cfg(not(unix))] + let _ = executable; + let mut target = parent.open_with(name, &options)?; + std::io::copy(&mut source, &mut target)?; + target.sync_all()?; + } + } + } + // Persist directory entries from leaves toward the root before publishing readiness. + fn sync(dir: &Dir) -> Result<(), StoreError> { + for entry in dir.entries()? { + let entry = entry?; + if entry.file_type()?.is_dir() { + sync(&dir.open_dir_nofollow(entry.file_name())?)?; + } + } + // cap-std directory handles can be O_PATH on Linux; reopen the same + // directory for I/O rather than passing a path-only descriptor to fsync. + dir.open(".")?.sync_all()?; + Ok(()) + } + sync(dir) +} diff --git a/platform/event-log/src/root_store/gates.rs b/platform/event-log/src/root_store/gates.rs index 1cad4197e37..a483cc40e16 100644 --- a/platform/event-log/src/root_store/gates.rs +++ b/platform/event-log/src/root_store/gates.rs @@ -30,6 +30,7 @@ use tokio_util::sync::CancellationToken; #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub(super) enum Key { Creation(String), + CheckpointLoad(String), // Session gates precede binding gates for operations that need both. Session(String), Work(String), diff --git a/platform/event-log/src/root_store/lifecycle.rs b/platform/event-log/src/root_store/lifecycle.rs index d249e485032..3f250671348 100644 --- a/platform/event-log/src/root_store/lifecycle.rs +++ b/platform/event-log/src/root_store/lifecycle.rs @@ -32,6 +32,7 @@ impl Lifecycle { pub(super) async fn start( logs: Vec>, gates: Arc, + inspections: Arc, ) -> Result { let (ready, started) = tokio::sync::oneshot::channel(); let (done, closed) = tokio::sync::oneshot::channel(); @@ -45,6 +46,7 @@ impl Lifecycle { { Ok(runtime) => runtime, Err(error) => { + inspections.request_shutdown(); for log in &logs { log.request_shutdown(); } @@ -59,6 +61,7 @@ impl Lifecycle { if ready.send(Ok(())).is_err() { gates.close(); } + let mut cached_inspections = Vec::new(); let result = std::panic::catch_unwind(AssertUnwindSafe(|| { let (unconfirmed, premature_close) = runtime.block_on(async { let receipts = logs.iter().map(|log| log.close_receipt()).collect::>(); @@ -88,8 +91,9 @@ impl Lifecycle { // can still persist known results before quarantining. return Err("accepted Root work cleanup is unconfirmed".into()); } - for log in &logs { log.request_shutdown(); } - let results = runtime.block_on(futures_util::future::join_all(logs.iter().map(|log| log.shutdown()))); + cached_inspections = inspections.take_all(); + for log in logs.iter().chain(&cached_inspections) { log.request_shutdown(); } + let results = runtime.block_on(futures_util::future::join_all(logs.iter().chain(&cached_inspections).map(|log| log.shutdown()))); let mut failures: Vec = results.into_iter().filter_map(Result::err).map(|error| error.to_string()).collect(); if let Some(error) = premature_close { failures.push(error); } if failures.is_empty() { @@ -103,6 +107,8 @@ impl Lifecycle { }); let failed = result.is_err(); if !failed { + drop(cached_inspections); + drop(inspections); drop(logs); drop(gates); } diff --git a/platform/event-log/src/root_store/session_metadata.rs b/platform/event-log/src/root_store/session_metadata.rs index 12eced09e7b..ee838da5a5b 100644 --- a/platform/event-log/src/root_store/session_metadata.rs +++ b/platform/event-log/src/root_store/session_metadata.rs @@ -131,6 +131,7 @@ impl SessionStore { false, permission_time, Some(root_app_control), + None, move |current| { *current = candidate; Ok(()) diff --git a/platform/event-log/src/sessions/metadata.rs b/platform/event-log/src/sessions/metadata.rs index 3bf4c57acb1..a168e9fe9cc 100644 --- a/platform/event-log/src/sessions/metadata.rs +++ b/platform/event-log/src/sessions/metadata.rs @@ -104,7 +104,7 @@ impl EventLog { T: Serialize + DeserializeOwned + Clone + PartialEq + Send + 'static, F: FnOnce(&mut T) -> Result<(), StoreError> + Send + 'static, { - self.mutate_session_metadata(id, expected, false, None, None, update) + self.mutate_session_metadata(id, expected, false, None, None, None, update) .await } @@ -122,7 +122,7 @@ impl EventLog { F: FnOnce(&mut T) -> Result<(), StoreError> + Send + 'static, { validate_time(now)?; - self.mutate_session_metadata(id, expected, false, Some(now), None, update) + self.mutate_session_metadata(id, expected, false, Some(now), None, None, update) .await } @@ -137,13 +137,14 @@ impl EventLog { where T: Serialize + DeserializeOwned + Clone + PartialEq + Send + 'static, { - self.mutate_session_metadata(id, expected, true, None, None, move |current| { + self.mutate_session_metadata(id, expected, true, None, None, None, move |current| { *current = configuration; Ok(()) }) .await } + #[allow(clippy::too_many_arguments)] // One SQL transaction owns metadata and optional checkpoint receipt. pub(crate) async fn mutate_session_metadata( &self, id: &str, @@ -151,6 +152,7 @@ impl EventLog { always_advance: bool, permission_time: Option, root_app_control: Option>, + checkpoint: Option, update: F, ) -> Result, StoreError> where @@ -169,6 +171,9 @@ impl EventLog { Box::pin(async move { let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?; super::removal::require_mutable(&mut tx, &id).await?; + if let Some(commit) = &checkpoint { + commit.validate(&mut tx).await?; + } let record: SessionRecord = read(&mut tx, &id) .await? .ok_or(StoreError::SessionNotFound)?; @@ -247,6 +252,9 @@ impl EventLog { let record = read(&mut tx, &id) .await? .ok_or(StoreError::SessionNotFound)?; + if let Some(commit) = checkpoint { + commit.record(&mut tx, next_revision).await?; + } tx.commit().await.map_err(StoreError::CommitUnknown)?; if approval_revoked { commits.activations(); diff --git a/plugins/agent/api/README.md b/plugins/agent/api/README.md index 7bf0db9127b..f347fbb76ed 100644 --- a/plugins/agent/api/README.md +++ b/plugins/agent/api/README.md @@ -68,29 +68,66 @@ Start the watch, wait for `ready`, then read the first list page and compare its On macOS, active watches also compare direct-child metadata. Each scan is followed by a pause of at least one second, extended to nine times the scan duration for expensive directories; this targets a 10% scanning duty cycle per subscription, rather than a fixed delivery deadline or total CPU limit. This detects persistent changes even when native notifications arrive late; unchanged scans emit nothing. Scans do not read file contents, follow symlinks or descend into child directories. `ready` waits for the initial scan; large directories or slow storage can delay readiness and later notifications. Closing a subscription waits for its active filesystem work to settle. -[Source](src/lib.rs) · [Cargo manifest](Cargo.toml) - `metadata.pinned` sets or clears conversation pinning; omission preserves it. Session results include `pinned`. Catalog pages place pinned conversations first, then keep the existing ID order, with revision-bound forward and backward cursors. New conversations and forks start unpinned. - `sessionMcp` is a user-only subscription that attaches client MCP servers to one idle Session. Retain the subscription for the connection lifetime and await its cleanup on close. It emits the attached tool names; credentials remain transient, and each new lease has a fresh identity. Closing fences queued work and settles its original execution before releasing the transports. ## History checkpoint management -The [checkpoint contract](src/contract/checkpoints.rs) exposes explicit, history-only management through the existing Agent endpoint: +The [checkpoint contract](src/contract/checkpoints.rs) exposes explicit checkpoint management through the existing Agent endpoint: | Method | Mode | Result | | --- | --- | --- | | `previewHistoryCheckpoint` | Query | Session closure membership and live revisions | -| `publishHistoryCheckpoint` | Mutation | Durable publication state and cleanup status | -| `historyCheckpointReceipt` | Query | Original Host observation and domain publication receipt | +| `publishCheckpoint` | Mutation | Durable publication state and cleanup status | +| `checkpointReceipt` | Query | Original Host observation and domain publication receipt | | `historyCheckpointHead` | Query | Current checkpoint head, or null | | `readHistoryCheckpoint` | Subscription | Exact retained native history bundle | +| `loadHistoryCheckpoint` | Mutation | Isolated read-only inspection state and member Session IDs | +| `historyCheckpointLoadReceipt` | Query | Original Host observation and domain load receipt | +| `restoreSessionCheckpoint` | Mutation | Rebound same-Root Session and materialized Workspace path | +| `sessionCheckpointRestoreReceipt` | Query | Original Host observation and domain restore receipt | Preview the source, then publish with its `confirmedSubtreeDigest` and an explicit `expectedRevision` (null only for the first head). The digest confirms membership, not an earlier content revision; the manifest records the actual consistent cut. Busy sources fail instead of queuing. Live messages do not advance the checkpoint head. Publication uses ordinary Host mutation preparation/confirmation; retain the original `RecoveryRef` **and input** before confirmation. Never replace an uncertain operation with a new ID. A CAS conflict is terminal for that operation; a newly chosen expectation requires a new operation. -`historyCheckpointReceipt` requires both endpoint access and original-operation recovery authorization. It performs a read-only lookup, including after Root reopen, and never recaptures current history. `publication.state.kind = published` confirms the checkpoint even if `operation.outcome` remains `unresolved` and its cleanup remains `unconfirmed`: these are separate transactions. The query does not forge Host completion or release original-operation custody. Null means no matching retained fact was found, not proof that execution cannot occur. `interrupted` identifies capture that could not complete; `prepared` is awaiting Root recovery, and `unavailable` needs investigation rather than recapture under the old operation. Publication cleanup failure does not undo an already committed head. +`checkpointReceipt` requires both endpoint access and original-operation recovery authorization. It performs a read-only lookup, including after Root reopen, and never recaptures current history. `publication.state.kind = published` confirms the checkpoint even if `operation.outcome` remains `unresolved` and its cleanup remains `unconfirmed`: these are separate transactions. The query does not forge Host completion or release original-operation custody. Null means no matching retained fact was found, not proof that execution cannot occur. `interrupted` identifies capture that could not complete; `prepared` is awaiting Root recovery, and `unavailable` needs investigation rather than recapture under the old operation. Publication cleanup failure does not undo an already committed head. + +For reads, first obtain a head, then request its exact revision. A moved head fails rather than selecting latest; old success receipts do not grant reads of old revisions. An admitted read retains its verified file and may finish if the head moves afterward. The subscription emits `checkpoint` metadata (with an optional Workspace reference), contiguous Base64 `chunk` frames (at most 32 KiB decoded), then `end` with the complete-object SHA-256 and byte length. The payload is native `MAKA-SESSION` v2, not tar.gz or the former TypeScript checkpoint format. Require matching metadata, contiguous offsets, digest, End, successful null completion and cleanup before accepting a download. Missing End is incomplete delivery. Backpressure bounds queued chunks; cancellation waits for the original reader to drain. No caller-provided file path is accepted. + +These are management methods, not model tools. Scope comes from the admitted Agent assembly; model-origin calls and foreign namespaces are rejected. With `workspaceConsistency: null`, `publishCheckpoint` includes only the native bundle's historical closure and referenced artifacts. Workspace and bounded recovery use the explicit methods below; neither transfers all Memory/Goal state or cross-Host execution authority. Publication currently requires the Root store's Unix implementation; unsupported platforms fail explicitly. Private immutable objects are conservatively retained; automatic triggers, UI and retention/GC remain separate work. Workspace publication and bounded local recovery use the explicit methods below. + +### Exact read-only loading -For reads, first obtain a head, then request its exact revision. A moved head fails rather than selecting latest; old success receipts do not grant reads of old revisions. An admitted read retains its verified file and may finish if the head moves afterward. The subscription emits `native_history_v1` metadata, contiguous Base64 `chunk` frames (at most 32 KiB decoded), then `end` with the complete-object SHA-256 and byte length. The payload is native `MAKA-SESSION` v2, not tar.gz or the former TypeScript checkpoint format. Require matching metadata, contiguous offsets, digest, End, successful null completion and cleanup before accepting a download. Missing End is incomplete delivery. Backpressure bounds queued chunks; cancellation waits for the original reader to drain. No caller-provided file path is accepted. +Call `loadHistoryCheckpoint` as a prepared Host mutation with `{ sessionId, revision }` from `historyCheckpointHead`. Retain its original `RecoveryRef` and input. The current retained revision is verified and claimed before destination writes; a moved Head rejects a new load. Once claimed, recovery finishes from the pinned manifest and bundle even if Head advances. The `ready` result contains an `inspection` locator and the imported `sessionIds`. + +The inspection preserves historical Session IDs in a separate private database. It does not overwrite live Sessions or register runnable copies in the ordinary catalog. No model, tool, pending-message consumer or background Agent task is started by loading. Source configuration is historical evidence, never a destination execution grant. When a Workspace is present, loading verifies its inventory and immutable objects and returns its consistency metadata; it does not copy the files into inspection storage. It never rebinds the live Session, starts execution, or imports into another Host. + +Use `historyCheckpointLoadReceipt` with `{ reference, input }` after an uncertain response. It observes the original Host operation and domain load independently, without starting another load. `preparing` awaits recovery from the original materials; `unavailable` requires investigation. A domain `ready` does not imply Host outcome/cleanup confirmation. Root startup resumes pending imports before serving inspections; an existing native import receipt reconciles a committed destination whose load receipt was lost. + +These reads take the returned `inspection` and a member `sessionId`: + +| Method | Additional input | Result | +| --- | --- | --- | +| `loadedHistoryCheckpointHistory` | `position`, optional `includeThinking` | Existing bounded `HistoryPage`, including tool-result text | +| `loadedHistoryCheckpointArtifacts` | `offset` | At most 32 artifact descriptors and `nextOffset` | +| `loadedHistoryCheckpointArtifact` | `artifactId`, byte `offset` | Subscription: contiguous chunks of at most 32 KiB with metadata; null if not found | -These are management methods, not model tools. Scope comes from the admitted Agent assembly; model-origin calls and foreign namespaces are rejected. The checkpoint includes only the native bundle's historical closure and referenced artifacts, not project files, all Memory/Goal state, execution authority or restore/activation. Publication currently requires the Root store's Unix implementation; unsupported platforms fail explicitly. Private immutable objects are conservatively retained; automatic triggers, UI, retention/GC and workspace capture remain separate work. +Use history cursors and artifact-list offsets for pagination. Each artifact subscription uses one read-only database connection for the entire transfer, with backpressure and cancellation; require successful completion and cleanup. Inspection reads do not acquire a writer lease, run migrations or sync directories, and can run concurrently. An inspection is retained independently of later Head changes. Inspection databases and immutable objects currently have no deletion/GC API; repeated **new** loads consume additional disk. Retrying the same original operation reuses its destination. These methods require the same management authorization and namespace as publication; locators are not bearer capabilities. + +### Workspace publication and local continuation + +`publishCheckpoint` takes `{ sessionId, expectedRevision, confirmedSubtreeDigest, workspaceConsistency }`; `checkpointReceipt` takes its original `{ reference, input }`. One API and manifest represent history-only (`workspaceConsistency: null`) and Workspace publication. With Workspace enabled, the ordinary native Session must be idle and have a single Workspace. Admission excludes that Session, overlapping Workspace execution, and its in-flight Goal producers. Global run/goal locks are not held during copying, so unrelated Sessions can continue. The source path comes from its validated Host binding. The manifest contains the native history bundle, its event-history cut, and an optional immutable Workspace inventory with content-addressed files. The history subscription streams the bundle and identifies any Workspace in the same `checkpoint` header. Publication/receipt output has `workspace: null` or `{ consistency, warning }`. + +For Workspace capture, `workspaceConsistency` is either `caller_confirmed_quiescent` or `unverified`. **Neither mode fences external writers or requires Apple entitlements.** Display `external_writers_not_fenced` to the user: keep editors, terminals, other agents and background writers stopped throughout capture. Caller confirmation is an assertion, not an OS snapshot proof. Without that assurance the result is explicitly unverified and must not be presented as a consistent/trusted checkpoint. Detected source changes abort publication in both modes; a successful hash check proves the saved bytes' integrity, not that files came from one point in time. The mode and warning survive manifest storage, loading and restoration. + +Capture honors project `.gitignore`, nested ignore files and `.git/info/exclude`, without global or ancestor ignore configuration. It additionally excludes `.git`, `node_modules`, `.DS_Store`, `__pycache__`, `.cache`, `.maka-workspace.json` and known credential filenames/directories (including real `.env` files). Excluded paths are omitted, not whole-capture errors. Ordinary `.pem` and `.key` fixtures are allowed; filenames are not a universal secret scanner. Selected symlinks, special files and nonportable paths fail explicitly. Regular files, executable bits and empty directories are preserved. There is no fixed file-count, byte-size or elapsed-time gate; disk costs grow with included data, and memory with inventory size. Large captures still incur I/O and retain their Session admission until physical completion. + +The API and private JSON are development contracts: no legacy publication endpoints, manifest variants or implicit missing-Workspace defaults are retained. Existing development checkpoint data in the former shape requires explicit offline migration or recreation. + +`restoreSessionCheckpoint` takes `{ checkpoint: { sessionId, revision }, parent: "/absolute/allowed/directory", permissions, backend, acceptExternalWritersRisk: true }`. Supply an absolute or `~`-prefixed path under an allowed directory root; physical identity is captured and rechecked by the Host, not supplied by the caller. The Host creates a **new** `maka-restored-…` child and never overwrites the original project. Only an ordinary native Session in the **same Root**, still at the captured event-history cut, is supported. Read markers, titles, pins and prior restores do not invalidate unchanged history; the current metadata revision is used only for the restore commit CAS. Advanced history, moved checkpoint Head, WorkHub, child Sessions, Goal state and executor backends are rejected. The target model and permissions are explicit; old grants are retired. An unverified source remains unverified after restoration. + +The restore atomically commits the new workspace binding and original restore receipt. It keeps the Session ID and existing checkpoint Head; a later publication uses that Head's revision as its CAS expectation. It does **not** roll back history or reconstruct a lost Root. Loading/rebinding does not execute a model or tool. Send an ordinary new message explicitly to continue; it uses the new Workspace. Cross-Root/Host authority handoff, arbitrary historical rollback, full auxiliary-state migration and mid-turn recovery are outside this implementation. + +After an uncertain restore response, use `sessionCheckpointRestoreReceipt` with the original `{ reference, input }`. Querying a committed receipt does not require the old destination path still to exist. A durable request alone does not own its destination: only a successfully copied and synced directory has its physical identity recorded. Retry and commit require that same directory, even if a replacement has identical contents. Before commit, occupied, altered or incomplete destinations fail closed and never rebind the Session; retain them for inspection and use a newly selected operation/destination after resolving the failed preparation. Restore also excludes in-flight Goal producers until the binding commits. No automatic deletion of user-visible recovery directories is performed. Private inspection databases, restored directories and immutable file objects consume retained disk; automatic GC is not implemented. + +[Source](src/lib.rs) · [Cargo manifest](Cargo.toml) diff --git a/plugins/agent/api/README.zh-CN.md b/plugins/agent/api/README.zh-CN.md index 1303c1f5e2c..2f2ed9c3c32 100644 --- a/plugins/agent/api/README.zh-CN.md +++ b/plugins/agent/api/README.zh-CN.md @@ -68,29 +68,66 @@ const operation = await context.services.agent[0].mutation('setCompactionPrompt' macOS 上,活动订阅还会比较直接子项的元数据。每次扫描后至少等待一秒;扫描耗时较长时,等待时间延长到该次扫描耗时的九倍,目标是每个订阅约用 10% 的时间扫描,并不承诺固定通知期限或限制总 CPU 用量。即使原生通知延迟,也能发现持续存在的变化;未变化时不发送通知。扫描不读取文件内容、不跟随符号链接,也不进入子目录。`ready` 会等待首次扫描;大目录或较慢的存储可能延迟就绪和后续通知。关闭订阅会等待其正在执行的文件系统工作结束。 -[源码](src/lib.rs) · [Cargo 配置](Cargo.toml) - `metadata.pinned` 用于置顶或取消置顶会话,省略时保留原状态。Session 结果包含 `pinned`。会话目录先返回置顶会话,再沿用原有 ID 顺序,前后翻页游标仍受目录 revision 约束。新会话和新分支默认不置顶。 - `sessionMcp` 是仅供用户调用的订阅,将客户端 MCP 服务器绑定到一个空闲 Session。连接期间持有该订阅,关闭时等待清理确认。订阅返回已绑定的工具名称;凭据仅保存在内存中,每次租约使用新身份。关闭会取消排队工作,等待原执行清理后再释放传输。 ## 历史 checkpoint 管理 -[Checkpoint 契约](src/contract/checkpoints.rs)通过现有 Agent endpoint 提供显式的 history-only 管理: +[Checkpoint 契约](src/contract/checkpoints.rs)通过现有 Agent endpoint 提供显式的 checkpoint 管理: | 方法 | 模式 | 返回内容 | | --- | --- | --- | | `previewHistoryCheckpoint` | Query | Session 历史闭包成员及实时 revision | -| `publishHistoryCheckpoint` | Mutation | 持久发布状态及清理状态 | -| `historyCheckpointReceipt` | Query | 原 Host 操作观测及领域发布回执 | +| `publishCheckpoint` | Mutation | 持久发布状态及清理状态 | +| `checkpointReceipt` | Query | 原 Host 操作观测及领域发布回执 | | `historyCheckpointHead` | Query | 当前 checkpoint Head,或 null | | `readHistoryCheckpoint` | Subscription | 指定 retained revision 的原生历史包 | +| `loadHistoryCheckpoint` | Mutation | 隔离只读检查区状态及导入的成员 Session ID | +| `historyCheckpointLoadReceipt` | Query | 原 Host 操作观测及领域加载回执 | +| `restoreSessionCheckpoint` | Mutation | 同 Root 重新绑定后的 Session 及恢复目录路径 | +| `sessionCheckpointRestoreReceipt` | Query | 原 Host 操作观测及领域恢复回执 | 先预览,再携带 `confirmedSubtreeDigest` 和显式 `expectedRevision` 发布;只有创建第一个 Head 时预期为 null。摘要确认成员集合,不固定此前的内容 revision;Manifest 记录实际一致捕获的切点。繁忙源直接失败,不进入等待队列。日常消息不推进 checkpoint Head。发布经过普通 Host mutation 的准备与确认,确认前保存原 `RecoveryRef` **及原始输入**。结果未知时查询原操作,不换 ID 重发。CAS 冲突对原操作是终态,修改期望需要新操作。 -`historyCheckpointReceipt` 同时要求 endpoint 访问和原操作恢复权限。包括 Root 重开后的查询都只读原事实,不重新捕获当前历史。`publication.state.kind = published` 确认存档成功,即使 `operation.outcome` 仍为 `unresolved`、清理仍为 `unconfirmed`:二者来自不同事务。查询不会伪造 Host 完成,也不释放原操作的工作持有权。null 只表示未找到对应保留事实,不证明操作不可能执行。`interrupted` 表示捕获未完成,`prepared` 等待 Root 恢复,`unavailable` 需要排查,不能用原操作重新捕获。发布后的清理失败不撤销已提交 Head。 +`checkpointReceipt` 同时要求 endpoint 访问和原操作恢复权限。包括 Root 重开后的查询都只读原事实,不重新捕获当前历史。`publication.state.kind = published` 确认存档成功,即使 `operation.outcome` 仍为 `unresolved`、清理仍为 `unconfirmed`:二者来自不同事务。查询不会伪造 Host 完成,也不释放原操作的工作持有权。null 只表示未找到对应保留事实,不证明操作不可能执行。`interrupted` 表示捕获未完成,`prepared` 等待 Root 恢复,`unavailable` 需要排查,不能用原操作重新捕获。发布后的清理失败不撤销已提交 Head。 + +读取时先获取 Head,再指定其精确 revision;Head 已移动则失败,不自动选择最新。历史成功回执不授予旧 revision 读取能力。已经受理的读取持有校验后的文件,随后 Head 移动仍可完成。流依次返回 `checkpoint` 元数据(带可空的 Workspace 引用)、连续偏移的 Base64 `chunk`(解码后每块最多 32 KiB)及含完整对象 SHA-256、字节数的 `end`。内容是原生 `MAKA-SESSION` v2,不是 tar.gz,也不兼容旧 TypeScript checkpoint 格式。接收方须检查元数据、连续偏移、摘要、End、成功的 null 完成及清理结果;缺少 End 就是不完整传输。背压限制排队数据块,取消等待原读取工作排空,不接受调用方任意文件路径。 + +这些方法属于管理入口,不是模型工具。Scope 来自已获准的 Agent 装配;模型来源调用和其他 namespace 被拒绝。`workspaceConsistency: null` 时,`publishCheckpoint` 只包含原生 Bundle 的历史闭包及关联 Artifact;Workspace 和受限恢复使用下文的显式方法。两者均不包含完整 Memory/Goal 状态或跨 Host 执行授权。发布目前依赖 Root 存储的 Unix 实现,不支持的平台显式失败。不可变对象保守保留,自动触发、UI 和 retention/GC 另行推进。 + +### 精确只读加载 -读取时先获取 Head,再指定其精确 revision;Head 已移动则失败,不自动选择最新。历史成功回执不授予旧 revision 读取能力。已经受理的读取持有校验后的文件,随后 Head 移动仍可完成。流依次返回 `native_history_v1` 元数据、连续偏移的 Base64 `chunk`(解码后每块最多 32 KiB)及含完整对象 SHA-256、字节数的 `end`。内容是原生 `MAKA-SESSION` v2,不是 tar.gz,也不兼容旧 TypeScript checkpoint 格式。接收方须检查元数据、连续偏移、摘要、End、成功的 null 完成及清理结果;缺少 End 就是不完整传输。背压限制排队数据块,取消等待原读取工作排空,不接受调用方任意文件路径。 +从 `historyCheckpointHead` 取得 `{ sessionId, revision }`,通过 Host 的 mutation 准备与确认调用 `loadHistoryCheckpoint`,保存原 `RecoveryRef` 和输入。写入目标之前先校验并登记当前 retained revision;Head 已移动则拒绝新的加载。登记成功后,即使 Head 推进,恢复仍使用已固定的 Manifest 和 Bundle。`ready` 返回 `inspection` 定位信息及导入的 `sessionIds`。 + +检查副本在独立私有数据库中保留历史 Session ID,不覆盖实时 Session,也不向普通目录注册可运行副本。加载不会启动模型、工具、待处理消息消费者或 Agent 后台任务。源配置只作为历史证据,不成为目标执行授权。若包含 Workspace,加载只验证清单及不可变文件对象并返回一致性信息,不在检查区复制文件、不切换实时目录或执行任务,也不向另一 Host 导入。 + +响应未知时,携带 `{ reference, input }` 查询 `historyCheckpointLoadReceipt`。它分别返回原 Host 操作观测与领域加载结果,不启动另一加载。`preparing` 等待从原材料恢复,`unavailable` 需要排查;领域 `ready` 不代表 Host 已确认结果或清理。Root 在开放检查接口前恢复未完成导入;若目标已提交而加载回执丢失,使用原生导入回执找回原结果。 + +以下读取接口携带返回的 `inspection` 和其中一个成员 `sessionId`: + +| 方法 | 其他输入 | 结果 | +| --- | --- | --- | +| `loadedHistoryCheckpointHistory` | `position`,可选 `includeThinking` | 既有有界 `HistoryPage`,包含工具结果文本 | +| `loadedHistoryCheckpointArtifacts` | `offset` | 最多 32 条 Artifact 元数据及 `nextOffset` | +| `loadedHistoryCheckpointArtifact` | `artifactId`、字节 `offset` | 订阅:连续返回元数据及最多 32 KiB 的 Base64 块;不存在返回 null | -这些方法属于管理入口,不是模型工具。Scope 来自已获准的 Agent 装配;模型来源调用和其他 namespace 被拒绝。存档只包含原生 Bundle 的历史闭包及关联 Artifact,不包含项目文件、完整 Memory/Goal 状态、执行授权或恢复激活。发布目前依赖 Root 存储的 Unix 实现,不支持的平台显式失败。不可变对象保守保留,自动触发、UI、retention/GC 和 Workspace 捕获另行推进。 +使用返回的历史游标和 Artifact 偏移分页,无需把整个 checkpoint 放入内存。每个 Artifact 订阅在完整传输期间只使用一条只读数据库连接,具备背压与取消排空;调用方须确认订阅完成及清理结果。检查区读取不申请写租约、不执行迁移、不同步目录,可并发读取。检查副本独立于后续 Head 变化保留。目前检查数据库和不可变对象没有删除/GC API;不断发起**新**加载会增加磁盘占用,原操作重试复用原目标。这些方法与发布使用相同的管理授权和 namespace;定位信息本身不授予访问权。 + +### Workspace 发布与本地继续执行 + +`publishCheckpoint` 输入 `{ sessionId, expectedRevision, confirmedSubtreeDigest, workspaceConsistency }`,`checkpointReceipt` 使用原始 `{ reference, input }`。统一接口及 Manifest 表达纯历史(`workspaceConsistency: null`)和带 Workspace 两种模式。带 Workspace 时只支持空闲的普通 native Session 和单个 Workspace;准入排除目标 Session、共享或嵌套 Workspace 的执行以及该 Session 尚未完成的 Goal producer。复制期间不持有全 Agent 的 runs/goals 锁,无关 Session 可以继续。路径来自 Host 验证过的绑定。Manifest 包含原生历史 Bundle、事件历史切面以及可空的 Workspace 清单/内容寻址文件对象。历史订阅使用同一种 `checkpoint` 首帧并传输 Bundle。发布及回执结果的 `workspace` 为 null 或 `{ consistency, warning }`。 + +捕获 Workspace 时必须显式选择 `caller_confirmed_quiescent`(调用方确认复制全程已停写)或 `unverified`(不能保证)。**两者都不隔离外部写入者,也不要求 Apple entitlement。** 客户端应显示 `external_writers_not_fenced` 提示:捕获期间需暂停编辑器、终端、其他 Agent 和后台程序的写入。用户确认只是前提声明,不是系统快照证明;无法保证时不得把产物宣称为一致、可信的 checkpoint。两种模式检测到变化都中止发布;摘要校验仅证明保存的字节完整,不能证明各文件来自同一时刻。此标记随 Manifest、加载和恢复保留。 + +捕获遵循项目 `.gitignore`、嵌套忽略文件和 `.git/info/exclude`,不读取全局或项目上层的忽略配置。额外排除 `.git`、`node_modules`、`.DS_Store`、`__pycache__`、`.cache`、`.maka-workspace.json` 及已知凭据文件名/目录(包括真实 `.env`)。这些路径被省略,不再导致整个捕获报错;普通 `.pem`、`.key` 测试夹具允许保留,但文件名规则并非通用秘密扫描器。被选中的符号链接、特殊文件和不可移植路径仍显式拒绝。保留普通文件、可执行位和空目录;不设固定文件数量、容量或时间门槛。磁盘成本随纳入数据量增长,内存随清单规模增长;大型捕获仍有 I/O 成本,完成前保留目标 Session 准入。 + +API 和私有 JSON 仍是开发中契约:不保留旧发布端点、Manifest 双轨或缺失 Workspace 字段的隐式兼容。旧形状的开发 checkpoint 数据需要显式离线迁移或重新生成。 + +`restoreSessionCheckpoint` 输入为 `{ checkpoint: { sessionId, revision }, parent: "/absolute/allowed/directory", permissions, backend, acceptExternalWritersRisk: true }`。输入为获准根内的绝对路径或 `~` 前缀路径,目录物理身份由 Host 获取并重新验证。Host 在父目录中新建 `maka-restored-…` 子目录,不覆盖原项目。首版仅支持**同 Root、事件历史仍与存档点相同**的普通 native Session;已读、改名、置顶、之前的恢复不会使未变化的历史失效,当前元数据 revision 仅用于提交 CAS。历史已推进、Head 已移动、WorkHub、子 Session、Goal 状态和 executor backend 都拒绝。显式选择目标模型与权限,撤销旧授权;unverified 不会因恢复变成可信。 + +恢复在同一事务提交目录绑定与原操作回执,保留 Session ID 和 checkpoint Head,下一次发布仍以原 Head revision 做 CAS。不会回滚新历史,也不重建已丢失的 Root;加载/重新绑定不会自动调用模型或工具,显式发送下一条普通消息后才从新目录执行。跨 Root/Host 权威交接、任意历史回滚、完整辅助状态迁移和轮次中间恢复不在本次范围。 + +回执未知时,使用原始 `{ reference, input }` 调用 `sessionCheckpointRestoreReceipt`;已提交回执不依赖目标目录仍在原位置。登记恢复请求不代表拥有目标目录;只有复制完成且落盘成功后,才记录实际创建目录的物理身份。重试与提交都要求仍是该目录,即便替换目录内容完全相同也拒绝。提交前若目标已被占用、不完整或被改变,则保持原 Session 不变,保留目录供检查;处理失败准备后使用新的操作/目标目录。恢复还会排除未完成的 Goal 写入,并持续到目录绑定提交。不自动删除用户可见的恢复目录;检查数据库、恢复目录和文件对象均占用持久磁盘,尚无自动 GC。 + +[源码](src/lib.rs) · [Cargo 配置](Cargo.toml) diff --git a/plugins/agent/api/src/contract.rs b/plugins/agent/api/src/contract.rs index b0f3c1e4b33..a51ebc45b8b 100644 --- a/plugins/agent/api/src/contract.rs +++ b/plugins/agent/api/src/contract.rs @@ -1484,6 +1484,55 @@ pub enum HistoryPage { }, } +impl From for HistoryPage { + fn from(value: maka_agent_ports::session::history::Page) -> Self { + let maka_agent_ports::session::history::Page::Ready { + through, + chunks, + before, + after, + } = value; + HistoryPage::Ready { + through, + before, + after, + chunks: chunks + .into_iter() + .map(|chunk| HistoryChunk { + model_part: chunk.model_part, + has_references: chunk.has_references, + final_output: chunk.final_output, + model: chunk.model, + origin: chunk.origin, + compaction: chunk.compaction, + attachments: chunk + .attachments + .into_iter() + .map(|attachment| HistoryAttachment { + name: attachment.name, + mime_type: attachment.mime_type, + bytes: attachment.bytes, + kind: attachment.kind, + }) + .collect(), + timing: chunk.timing, + field: chunk.field, + message_id: chunk.message_id, + turn_id: chunk.turn_id, + timestamp: chunk.timestamp, + role: chunk.role, + order: chunk.order, + position: chunk.position, + offset: chunk.offset, + total_bytes: chunk.total_bytes, + text: chunk.text, + tool: chunk.tool, + }) + .collect(), + } + } +} + #[derive(Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[schemars(rename = "AgentHistorySourceRead")] @@ -1842,12 +1891,12 @@ pub fn contract() -> Contract { method::(MethodMode::Query), ), ( - "publishHistoryCheckpoint".into(), - method::(MethodMode::Mutation), + "publishCheckpoint".into(), + method::(MethodMode::Mutation), ), ( - "historyCheckpointReceipt".into(), - method::(MethodMode::Query), + "checkpointReceipt".into(), + method::(MethodMode::Query), ), ( "historyCheckpointHead".into(), @@ -1857,6 +1906,13 @@ pub fn contract() -> Contract { "readHistoryCheckpoint".into(), method::>(MethodMode::Subscription), ), + ("loadHistoryCheckpoint".into(), method::(MethodMode::Mutation)), + ("restoreSessionCheckpoint".into(), method::(MethodMode::Mutation)), + ("sessionCheckpointRestoreReceipt".into(), method::(MethodMode::Query)), + ("historyCheckpointLoadReceipt".into(), method::(MethodMode::Query)), + ("loadedHistoryCheckpointHistory".into(), method::(MethodMode::Query)), + ("loadedHistoryCheckpointArtifacts".into(), method::(MethodMode::Query)), + ("loadedHistoryCheckpointArtifact".into(), method::>(MethodMode::Subscription)), ( "fork".into(), method::(MethodMode::Mutation), diff --git a/plugins/agent/api/src/contract/checkpoints.rs b/plugins/agent/api/src/contract/checkpoints.rs index 9c75f9d6bc8..a450a8fabb6 100644 --- a/plugins/agent/api/src/contract/checkpoints.rs +++ b/plugins/agent/api/src/contract/checkpoints.rs @@ -17,16 +17,43 @@ * under the License. */ -//! History-only checkpoints; neither workspace snapshots nor execution authority. +//! Explicit history/Workspace checkpoints, private inspections and bounded local recovery. use maka_plugin_core::operation::{OperationObservation, RecoveryRef}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; +#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[schemars(rename = "AgentCheckpointWorkspaceConsistency")] +pub enum CheckpointWorkspaceConsistency { + /// The caller confirms all other writers remain paused for the entire copy. + CallerConfirmedQuiescent, + /// Files may not represent one instant; successful digest checks do not change this. + Unverified, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentCheckpointWorkspaceNotice")] +pub struct CheckpointWorkspaceNotice { + pub consistency: CheckpointWorkspaceConsistency, + pub warning: CheckpointWorkspaceWarning, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[schemars(rename = "AgentCheckpointWorkspaceWarning")] +pub enum CheckpointWorkspaceWarning { + /// Display to users: pause editors, terminals and other writers for the entire + /// capture. Hash verification is not a point-in-time consistency guarantee. + ExternalWritersNotFenced, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(rename = "AgentHistoryCheckpointPublish")] -pub struct HistoryCheckpointPublish { +#[schemars(rename = "AgentCheckpointPublish")] +pub struct CheckpointPublish { #[schemars(length(min = 1))] pub session_id: String, #[schemars(with = "maka_plugin_core::wire_schema::RequiredNullable")] @@ -34,6 +61,12 @@ pub struct HistoryCheckpointPublish { /// Membership confirmation returned by previewHistoryCheckpoint, not a live revision. #[schemars(regex(pattern = "^[a-f0-9]{64}$"))] pub confirmed_subtree_digest: String, + /// Null captures history only. Otherwise the caller explicitly selects the + /// Workspace consistency guarantee; other applications are never fenced. + #[schemars( + with = "maka_plugin_core::wire_schema::RequiredNullable" + )] + pub workspace_consistency: Option, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] @@ -70,11 +103,12 @@ pub struct HistoryCheckpointSession { } #[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(rename = "AgentHistoryCheckpointPublication")] -pub struct HistoryCheckpointPublication { +#[schemars(rename = "AgentCheckpointPublication")] +pub struct CheckpointPublication { pub state: HistoryCheckpointState, /// Cleanup failure does not undo a successful publication. pub cleanup_pending: bool, + pub workspace: Option, } #[derive(Serialize, Deserialize, JsonSchema)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] @@ -89,21 +123,21 @@ pub enum HistoryCheckpointState { } #[derive(Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(rename = "AgentHistoryCheckpointReceiptRead")] -pub struct HistoryCheckpointReceiptRead { +#[schemars(rename = "AgentCheckpointReceiptRead")] +pub struct CheckpointReceiptRead { pub reference: RecoveryRef, /// The exact original input. Changed input cannot select another publication. - pub input: HistoryCheckpointPublish, + pub input: CheckpointPublish, } #[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(rename = "AgentHistoryCheckpointReceipt")] -pub struct HistoryCheckpointReceipt { +#[schemars(rename = "AgentCheckpointReceipt")] +pub struct CheckpointReceipt { /// Original Host observation. Domain success never fabricates Host cleanup confirmation. pub operation: Option, /// Authoritative domain result even when the Host response was lost. /// Absence is not permission to recapture under a replacement operation. - pub publication: Option, + pub publication: Option, } #[derive(Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -123,11 +157,12 @@ pub struct HistoryCheckpointRead { )] #[schemars(rename = "AgentHistoryCheckpointFrame")] pub enum HistoryCheckpointFrame { - /// The following bytes use the MAKA-SESSION v2 history format. - NativeHistoryV1 { + /// MAKA-SESSION v2 history bytes follow; Workspace objects are separate. + Checkpoint { head: HistoryCheckpointHead, inventory: HistoryCheckpointInventory, bundle: HistoryCheckpointObject, + workspace: Option, }, Chunk { #[schemars(range(max = 9_007_199_254_740_991u64))] @@ -142,6 +177,125 @@ pub enum HistoryCheckpointFrame { }, } +#[derive(Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentCheckpointInspection")] +pub struct CheckpointInspection { + #[schemars(length(min = 1))] + pub source_session_id: String, + pub operation_id: Uuid, +} +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentCheckpointLoad")] +pub struct CheckpointLoad { + pub head: HistoryCheckpointHead, + pub inspection: CheckpointInspection, + pub state: CheckpointLoadState, +} +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +#[schemars(rename = "AgentCheckpointLoadState")] +pub enum CheckpointLoadState { + Preparing, + Ready { + session_ids: Vec, + workspace: Option, + }, + Unavailable, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentCheckpointWorkspaceSnapshot")] +pub struct CheckpointWorkspaceSnapshot { + pub identity: String, + pub consistency: CheckpointWorkspaceConsistency, + pub warning: CheckpointWorkspaceWarning, + pub inventory: HistoryCheckpointObject, +} +#[derive(Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentCheckpointLoadReceiptRead")] +pub struct CheckpointLoadReceiptRead { + pub reference: RecoveryRef, + pub input: HistoryCheckpointRead, +} +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentCheckpointLoadReceipt")] +pub struct CheckpointLoadReceipt { + pub operation: Option, + pub load: Option, +} +#[derive(Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentCheckpointHistoryRead")] +pub struct CheckpointHistoryRead { + pub inspection: CheckpointInspection, + #[schemars(length(min = 1))] + pub session_id: String, + pub position: super::HistoryPosition, + #[serde(default)] + pub include_thinking: bool, +} +#[derive(Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentCheckpointArtifactsRead")] +pub struct CheckpointArtifactsRead { + pub inspection: CheckpointInspection, + #[schemars(length(min = 1))] + pub session_id: String, + #[schemars(range(max = 9_007_199_254_740_991u64))] + pub offset: u64, +} +#[derive(Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentCheckpointArtifactRead")] +pub struct CheckpointArtifactRead { + pub inspection: CheckpointInspection, + #[schemars(length(min = 1))] + pub session_id: String, + #[schemars(length(min = 1))] + pub artifact_id: String, + #[schemars(range(max = 9_007_199_254_740_991u64))] + pub offset: u64, +} +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentCheckpointArtifact")] +pub struct CheckpointArtifact { + pub id: String, + pub name: String, + pub mime_type: Option, + #[schemars(range(max = 9_007_199_254_740_991u64))] + pub bytes: u64, +} +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentCheckpointArtifacts")] +pub struct CheckpointArtifacts { + pub artifacts: Vec, + #[schemars(with = "maka_plugin_core::wire_schema::RequiredNullable")] + pub next_offset: Option, +} +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentCheckpointArtifactChunk")] +pub struct CheckpointArtifactChunk { + pub artifact: CheckpointArtifact, + #[schemars(range(max = 9_007_199_254_740_991u64))] + pub offset: u64, + /// Base64, at most 32 KiB decoded. Empty only at the end. + #[schemars(length(max = 43692))] + pub data: String, +} + #[cfg(test)] mod tests { use super::*; @@ -153,19 +307,19 @@ mod tests { let Interface::Service { methods } = crate::contract().interface else { unreachable!() }; - assert_eq!( - methods["publishHistoryCheckpoint"].mode, - MethodMode::Mutation - ); + assert_eq!(methods["publishCheckpoint"].mode, MethodMode::Mutation); assert_eq!( methods["readHistoryCheckpoint"].mode, MethodMode::Subscription ); - let publish = - jsonschema::validator_for(&methods["publishHistoryCheckpoint"].input).unwrap(); - let valid = json!({"sessionId":"source","expectedRevision":null,"confirmedSubtreeDigest":"a".repeat(64)}); + let publish = jsonschema::validator_for(&methods["publishCheckpoint"].input).unwrap(); + let valid = json!({"sessionId":"source","expectedRevision":null,"confirmedSubtreeDigest":"a".repeat(64),"workspaceConsistency":null}); assert!(publish.is_valid(&valid)); - for key in ["expectedRevision", "confirmedSubtreeDigest"] { + for key in [ + "expectedRevision", + "confirmedSubtreeDigest", + "workspaceConsistency", + ] { let mut absent = valid.clone(); absent.as_object_mut().unwrap().remove(key); assert!(!publish.is_valid(&absent)); @@ -186,4 +340,84 @@ mod tests { assert!(!read.is_valid(&json!({"sessionId":"source","revision":23}))); assert!(read.is_valid(&json!({"sessionId":"source","revision":Uuid::new_v4()}))); } + #[test] + fn checkpoint_load_requires_exact_revision_and_never_accepts_execution_authority() { + let Interface::Service { methods } = crate::contract().interface else { + unreachable!() + }; + assert_eq!(methods["loadHistoryCheckpoint"].mode, MethodMode::Mutation); + for method in [ + "historyCheckpointLoadReceipt", + "loadedHistoryCheckpointHistory", + "loadedHistoryCheckpointArtifacts", + ] { + assert_eq!(methods[method].mode, MethodMode::Query); + } + let validator = jsonschema::validator_for(&methods["loadHistoryCheckpoint"].input).unwrap(); + let valid = json!({"sessionId":"source", "revision":Uuid::new_v4()}); + assert!(validator.is_valid(&valid)); + assert!(!validator.is_valid(&json!({"sessionId":"source"}))); + for (key, value) in [ + ("revision", json!(null)), + ("path", json!("/tmp/target")), + ("activate", json!(true)), + ("configuration", json!({})), + ("scope", json!({})), + ] { + let mut invalid = valid.clone(); + invalid[key] = value; + assert!(!validator.is_valid(&invalid), "{key}"); + } + assert_eq!( + methods["loadedHistoryCheckpointArtifact"].mode, + MethodMode::Subscription + ); + let validator = + jsonschema::validator_for(&methods["loadedHistoryCheckpointArtifact"].input).unwrap(); + let mut valid = json!({"inspection":{"sourceSessionId":"source","operationId":Uuid::new_v4()},"sessionId":"source","artifactId":"a","offset":0}); + assert!(validator.is_valid(&valid)); + valid["offset"] = json!(9_007_199_254_740_992u64); + assert!(!validator.is_valid(&valid)); + } +} + +/// Restore files into a new child of a selected directory and rebind the same, +/// unchanged local Session. It never starts a model/tool call or rewinds history. +#[derive(Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentSessionCheckpointRestore")] +pub struct SessionCheckpointRestore { + pub checkpoint: HistoryCheckpointRead, + /// Absolute parent path. The Host captures and validates its directory identity. + #[schemars(length(min = 1))] + pub parent: String, + pub permissions: super::WorkspacePermissions, + pub backend: super::BackendPreference, + pub accept_external_writers_risk: bool, +} +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentSessionCheckpointRestored")] +pub struct SessionCheckpointRestored { + pub operation: Uuid, + pub session_id: String, + #[schemars(range(max = 9_007_199_254_740_991u64))] + pub session_revision: u64, + pub head: HistoryCheckpointHead, + pub workspace: CheckpointWorkspaceSnapshot, + pub path: String, +} +#[derive(Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentSessionCheckpointRestoreReceiptRead")] +pub struct SessionCheckpointRestoreReceiptRead { + pub reference: RecoveryRef, + pub input: SessionCheckpointRestore, +} +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(rename = "AgentSessionCheckpointRestoreReceipt")] +pub struct SessionCheckpointRestoreReceipt { + pub operation: Option, + pub restore: Option, } diff --git a/plugins/agent/api/src/session.rs b/plugins/agent/api/src/session.rs index b3340fb1f4f..9983221a8fc 100644 --- a/plugins/agent/api/src/session.rs +++ b/plugins/agent/api/src/session.rs @@ -107,23 +107,4 @@ impl ResolvedConfiguration { binding: self.binding, } } - - /// Attach to a new binding after the old effective boundary was resolved. - /// The Session metadata transaction validates the reference's current epochs. - pub fn into_stored(self, workspace: BindingRef) -> StoredSessionConfiguration { - StoredSessionConfiguration { - parent_session_id: self.parent_session_id, - backend: self.backend, - collaboration_mode: self.collaboration_mode, - tools: self.tools, - compaction_prompt: self.compaction_prompt, - title: self.title, - title_is_manual: self.title_is_manual, - pinned: self.pinned, - labels: self.labels, - workspace, - permissions: self.permissions, - boundary_revision: self.boundary_revision, - } - } } diff --git a/plugins/agent/native/README.md b/plugins/agent/native/README.md index 2a4b33c42c3..0ae166af514 100644 --- a/plugins/agent/native/README.md +++ b/plugins/agent/native/README.md @@ -21,14 +21,16 @@ [简体中文](README.zh-CN.md) -[`HistoryCheckpointPublisher`](src/checkpoints.rs) binds the admitted native Session +[`CheckpointPublisher`](src/checkpoints.rs) binds the admitted native Session namespace and original `RecoveryRef` to Root-owned history capture and recovery. The [service adapter](src/owner/checkpoints.rs) exposes explicit management methods with Host admission, original receipt authorization and bounded subscription reads. Publication receipts resolve checkpoint success without fabricating generic Host cleanup confirmation. See the [API workflow](../api/README.md#history-checkpoint-management) -and [manual baseline](src/owner/tests/checkpoints/benchmark.rs). Workspace capture, -restore/activation and model tools are outside this API. +and [manual baseline](src/owner/tests/checkpoints/benchmark.rs). Model tools and +cross-Root activation remain outside this API. + +The [inspection adapter](src/owner/checkpoints/load.rs) adds exact checkpoint loading and bounded history/artifact queries through the same Host management boundary. Loaded history stays in isolated storage with its original IDs; it is never registered with this Owner's execution or message recovery. Query `historyCheckpointLoadReceipt` using the original operation and input after an uncertain reply. Workspace publication and explicit same-Root directory restoration are handled by the [Session adapter](src/owner/checkpoints/session.rs) and [restore adapter](src/owner/checkpoints/restore.rs). They retain conditional consistency warnings, require explicit target permissions/model selection, and never auto-send a message. See the [supported scope](../api/README.md#workspace-publication-and-local-continuation). `agent.fork` captures a source revision and an explicit `position`: `end`, `through_turn`, or `before_turn` with a logical Turn ID, or `start` for empty history. `before_turn` excludes the complete target Turn and accepts the first Turn to create an empty conversation. The Host resolves the boundary from canonical history in the copy transaction; clients do not need a preceding Turn or the sampled history landmarks. The returned branch retains that position, and the source conversation is unchanged. diff --git a/plugins/agent/native/README.zh-CN.md b/plugins/agent/native/README.zh-CN.md index 16fcbe287e4..6ad93e6f1e6 100644 --- a/plugins/agent/native/README.zh-CN.md +++ b/plugins/agent/native/README.zh-CN.md @@ -21,12 +21,14 @@ [English](README.md) -[`HistoryCheckpointPublisher`](src/checkpoints.rs) 将已获准的原生 Session namespace +[`CheckpointPublisher`](src/checkpoints.rs) 将已获准的原生 Session namespace 和原 `RecoveryRef` 绑定到 Root 持有的历史捕获与恢复流程。 [服务适配层](src/owner/checkpoints.rs)通过 Host 准入、原操作回执授权和有界订阅读取提供显式管理方法。 领域回执可确认 checkpoint 成功,但不伪造通用 Host 的清理确认。 参见 [API 工作流](../api/README.zh-CN.md#历史-checkpoint-管理)和[手动性能基线](src/owner/tests/checkpoints/benchmark.rs)。 -Workspace 捕获、恢复激活和模型工具不在此 API 范围内。 +模型工具和跨 Root 恢复激活不在此 API 范围内;Workspace 与同 Root 恢复见下文。 + +[检查适配层](src/owner/checkpoints/load.rs)通过相同 Host 管理边界提供精确加载及有界历史、Artifact 查询。加载的历史保留原 ID,放在隔离存储中,不向本 Owner 的执行或消息恢复注册。响应未知时使用原操作及原输入查询 `historyCheckpointLoadReceipt`。Workspace 发布与显式的同 Root 目录恢复分别见 [Session 适配器](src/owner/checkpoints/session.rs)和[恢复适配器](src/owner/checkpoints/restore.rs)。这两者保留条件一致性提示,要求明确的目标权限与模型,不自动发起消息。跨 Root 恢复及系统级外部停写仍未提供,详见 [API 支持范围](../api/README.zh-CN.md#workspace-发布与本地继续执行)。 `agent.fork` 捕获源会话版本和显式 `position`:`end`、带逻辑 Turn ID 的 `through_turn` 或 `before_turn`,以及表示空历史的 `start`。`before_turn` 排除目标完整轮次,指定第一轮可创建空对话。Host 在复制事务中从规范历史计算边界,客户端无需寻找上一轮或依赖采样的历史导航点。返回的分支保留该位置,源会话保持不变。 diff --git a/plugins/agent/native/src/checkpoints.rs b/plugins/agent/native/src/checkpoints.rs index cfd4ac498b0..bf837303cde 100644 --- a/plugins/agent/native/src/checkpoints.rs +++ b/plugins/agent/native/src/checkpoints.rs @@ -28,7 +28,7 @@ use maka_plugin_core::operation::RecoveryRef; use std::sync::Arc; #[derive(Clone)] -pub struct HistoryCheckpointPublisher { +pub struct CheckpointPublisher { store: Arc, scope: Scope, prefix: String, @@ -39,9 +39,10 @@ pub struct Capture { pub session_id: String, pub expected_checkpoint_revision: Option, pub confirmed_subtree_digest: String, + pub workspace: Option, } -impl HistoryCheckpointPublisher { +impl CheckpointPublisher { /// Called by trusted native assembly, never with model-authored scope. pub fn new(store: Arc, scope: Scope, coordinator: bool) -> Self { let prefix = maka_agent_authority::session_namespace( @@ -57,7 +58,7 @@ impl HistoryCheckpointPublisher { } } - async fn repository(&self, session: &str) -> Result { + pub(crate) async fn repository(&self, session: &str) -> Result { if !crate::owner::owns_session(&self.prefix, session) { return Err(StoreError::SessionNotFound); } @@ -66,7 +67,7 @@ impl HistoryCheckpointPublisher { .await } - fn request( + pub(crate) fn request( &self, capture: &Capture, operation: &RecoveryRef, @@ -78,7 +79,7 @@ impl HistoryCheckpointPublisher { || operation.source.deployment != self.scope.deployment || operation.namespace != self.scope.namespace || operation.export != maka_agent_api::EXPORT - || operation.method != "publishHistoryCheckpoint" + || operation.method != "publishCheckpoint" { return Err(StoreError::InvalidTransition( "checkpoint operation belongs to another authority".into(), @@ -90,6 +91,7 @@ impl HistoryCheckpointPublisher { fingerprint: hex::encode(sha2::Sha256::digest(serde_json::to_vec(operation)?)), expected_revision: capture.expected_checkpoint_revision, confirmed_subtree_digest: capture.confirmed_subtree_digest.clone(), + workspace: capture.workspace.clone(), }) } @@ -104,6 +106,33 @@ impl HistoryCheckpointPublisher { self.request(capture, operation).map(|_| ()) } + pub(crate) fn load_request( + &self, + input: &maka_agent_api::contract::HistoryCheckpointRead, + operation: &RecoveryRef, + ) -> Result { + operation + .validate() + .map_err(|e| StoreError::InvalidTransition(e.to_string()))?; + if !crate::owner::owns_session(&self.prefix, &input.session_id) + || operation.source.state_domain != self.scope.state_domain + || operation.source.deployment != self.scope.deployment + || operation.namespace != self.scope.namespace + || operation.export != maka_agent_api::EXPORT + || operation.method != "loadHistoryCheckpoint" + { + return Err(StoreError::InvalidTransition( + "checkpoint load belongs to another authority".into(), + )); + } + use sha2::Digest; + Ok(maka_event_log::checkpoints::LoadOperation { + operation: operation.operation, + fingerprint: hex::encode(sha2::Sha256::digest(serde_json::to_vec(operation)?)), + revision: Revision(input.revision), + }) + } + pub async fn publish( &self, capture: Capture, @@ -174,7 +203,7 @@ mod tests { deployment: Uuid::from_u128(1), namespace: InstanceId(vec![Uuid::from_u128(2)]), }; - let publisher = HistoryCheckpointPublisher::new(store.clone(), scope.clone(), false); + let publisher = CheckpointPublisher::new(store.clone(), scope.clone(), false); let id = format!("{}{}", publisher.prefix, "1".repeat(32)); store .create_session(&id, "create", &serde_json::json!({}), 1) @@ -184,6 +213,7 @@ mod tests { session_id: id.clone(), expected_checkpoint_revision: None, confirmed_subtree_digest: store.preview_bundle(&id).await.unwrap().subtree_digest, + workspace: None, }; let operation = RecoveryRef { source: DeploymentRef { @@ -193,7 +223,7 @@ mod tests { }, namespace: scope.namespace, export: maka_agent_api::EXPORT.into(), - method: "publishHistoryCheckpoint".into(), + method: "publishCheckpoint".into(), operation: Uuid::from_u128(3), fingerprint: format!("sha256-{}", "b".repeat(64)), contract: RecoveryContract { diff --git a/plugins/agent/native/src/mcp/projects/client.rs b/plugins/agent/native/src/mcp/projects/client.rs index 40bb81f115b..6a9c28648ea 100644 --- a/plugins/agent/native/src/mcp/projects/client.rs +++ b/plugins/agent/native/src/mcp/projects/client.rs @@ -194,6 +194,22 @@ impl Projects { .get(session) .is_some_and(|client| client.generation == generation) } + pub fn has_any_client(&self, session: &str) -> bool { + self.snapshots + .read() + .expect("MCP snapshots") + .clients + .contains_key(session) + } + pub fn client_sessions(&self) -> Vec { + self.snapshots + .read() + .expect("MCP snapshots") + .clients + .keys() + .cloned() + .collect() + } pub fn client_cleanup_failed(&self, error: String) { *self.cleanup_failure.lock().expect("MCP cleanup state") = Some(error); } diff --git a/plugins/agent/native/src/owner.rs b/plugins/agent/native/src/owner.rs index 3ab76c205ee..ea7c4026242 100644 --- a/plugins/agent/native/src/owner.rs +++ b/plugins/agent/native/src/owner.rs @@ -176,6 +176,7 @@ struct Control { } #[derive(Default)] struct Runs { + checkpoints: Arc>>>, closing: bool, controls: BTreeMap, preparing: BTreeMap, @@ -184,7 +185,9 @@ struct Runs { } impl Runs { fn busy(&self, session: &str) -> bool { - self.controls.contains_key(session) || self.preparing.contains_key(session) + self.controls.contains_key(session) + || self.preparing.contains_key(session) + || self.checkpoints.lock().unwrap().contains_key(session) } } struct CreationPolicy { @@ -759,6 +762,22 @@ impl Owner { .ok_or("agent_session_unavailable")?; resolve_session(session) } + pub(in crate::owner) async fn mutable_session( + &self, + id: &str, + ) -> Result, String> { + if self.runs.checkpointed(id) { + return Err("agent_checkpoint_busy".into()); + } + let session = self.session(id).await?; + if !self.runs.workspace_available( + id, + std::path::Path::new(&session.configuration.workspace.path), + ) { + return Err("agent_checkpoint_busy".into()); + } + Ok(session) + } pub(crate) fn caller_store( &self, scope: &maka_capabilities::call::Scope, @@ -994,7 +1013,17 @@ impl Owner { }) } pub async fn mark_read(&self, input: MarkRead) -> Result { + if self.runs.checkpointed(&input.session_id) { + return Ok(MarkReadResult::Rejected { + reason: Rejection::Busy, + }); + } let runs = self.runs.lock().await; + if self.runs.checkpointed(&input.session_id) { + return Ok(MarkReadResult::Rejected { + reason: Rejection::Busy, + }); + } if runs.closing { return Ok(MarkReadResult::Rejected { reason: Rejection::Closed, @@ -1047,7 +1076,17 @@ impl Owner { if let Some(reason) = metadata_rejection(&input.title, &input.labels) { return Ok(MetadataResult::Rejected { reason }); } + if self.runs.checkpointed(&input.session_id) { + return Ok(MetadataResult::Rejected { + reason: Rejection::Busy, + }); + } let runs = self.runs.lock().await; + if self.runs.checkpointed(&input.session_id) { + return Ok(MetadataResult::Rejected { + reason: Rejection::Busy, + }); + } if runs.closing { return Ok(MetadataResult::Rejected { reason: Rejection::Closed, @@ -1263,7 +1302,7 @@ impl Owner { .as_ref() .map(|parked| parked.checkpoint.invocation.clone()) }); - let session = self.session(&id).await?; + let session = self.mutable_session(&id).await?; if session.archived { return Err("agent_session_configuration_changed".into()); } @@ -1456,7 +1495,7 @@ impl Owner { .map_err(|_| "agent_execution_unavailable")? .map(|parked| parked.checkpoint.invocation), }; - let current = self.session(&id).await?; + let current = self.mutable_session(&id).await?; if current.configuration != session.configuration || current.archived { return Err("agent_session_configuration_changed".into()); } @@ -1694,6 +1733,7 @@ impl Owner { let runs = self.runs.clone().try_lock_owned().ok()?; if !runs.controls.is_empty() || !runs.preparing.is_empty() + || self.runs.has_checkpoints() || !self.workers.is_empty() || self.auxiliary.resources.has_pending() || self.goals.keeps_host_awake() diff --git a/plugins/agent/native/src/owner/attachments.rs b/plugins/agent/native/src/owner/attachments.rs index 11e3a8e2082..edf5be496a2 100644 --- a/plugins/agent/native/src/owner/attachments.rs +++ b/plugins/agent/native/src/owner/attachments.rs @@ -62,7 +62,7 @@ impl Owner { reason: AttachmentRejection::Closed, }); } - if owner.session(&input.session_id).await?.archived { + if owner.mutable_session(&input.session_id).await?.archived { return Ok(AttachmentUploadResult::Rejected { reason: AttachmentRejection::Archived, }); diff --git a/plugins/agent/native/src/owner/checkpoints.rs b/plugins/agent/native/src/owner/checkpoints.rs index 72715832038..cd3da62867d 100644 --- a/plugins/agent/native/src/owner/checkpoints.rs +++ b/plugins/agent/native/src/owner/checkpoints.rs @@ -19,7 +19,10 @@ //! Authorized history publication and bounded reads through the Agent service. use super::*; -use crate::checkpoints::{Capture, HistoryCheckpointPublisher}; +mod load; +mod restore; +mod session; +use crate::checkpoints::{Capture, CheckpointPublisher}; use base64::{Engine as _, engine::general_purpose::STANDARD}; use maka_capabilities::call::Scope; use maka_event_log::{StoreError, checkpoints as storage}; @@ -27,9 +30,162 @@ use maka_host_runtime::application::Context; use maka_plugin_core::operation::RecoveryRef; use tokio::io::AsyncReadExt; +pub(super) struct CheckpointAdmission { + _workspace: runs::Checkpoint, + _session: runs::Ordered, + _goal: runs::Ordered, + _wake: WakeAfterCheckpoint, +} + +struct WakeAfterCheckpoint(std::sync::Weak); + +impl Drop for WakeAfterCheckpoint { + fn drop(&mut self) { + if let Some(owner) = self.0.upgrade() { + owner.wake_after_checkpoint(); + } + } +} + impl Owner { - fn history_checkpoints(&self) -> HistoryCheckpointPublisher { - HistoryCheckpointPublisher::new( + fn wake_after_checkpoint(self: &Arc) { + if self.stop.is_cancelled() || self.workers.is_closed() { + return; + } + self.goals.execution_changed(); + if tokio::runtime::Handle::try_current().is_ok() { + let owner = self.clone(); + self.workers.spawn(async move { + if owner.stop.is_cancelled() { + return; + } + owner.schedule().await; + let _ = owner.schedule_activations(now()).await; + }); + } + } + + pub(super) async fn checkpoint_admission( + self: &Arc, + id: &str, + extra_path: Option<&std::path::Path>, + ) -> Result<(CheckpointAdmission, SessionRecord), String> { + let goal = self + .goals + .admissions + .try_ordered(id) + .map_err(|_| "agent_checkpoint_busy")?; + let ordered = self + .runs + .try_ordered(id) + .map_err(|_| "agent_checkpoint_busy")?; + let session = self.session(id).await?; + let mut paths = vec![std::path::PathBuf::from( + &session.configuration.workspace.path, + )]; + if let Some(extra) = extra_path { + let extra = extra.to_path_buf(); + if !paths.contains(&extra) { + paths.push(extra); + } + } + let workspace = self.runs.checkpoint(id, paths.clone())?; + let admission = CheckpointAdmission { + _workspace: workspace, + _session: ordered, + _goal: goal, + _wake: WakeAfterCheckpoint(Arc::downgrade(self)), + }; + let mut active = { + let runs = self.runs.state().await; + if runs.closing + || self.stop.is_cancelled() + || runs.controls.contains_key(id) + || runs.preparing.contains_key(id) + || runs.retained_cleanup.contains(id) + || self.mcp_projects.has_any_client(id) + { + return Err("agent_checkpoint_busy".into()); + } + runs.controls + .keys() + .chain(runs.preparing.keys()) + .chain(runs.retained_cleanup.iter()) + .cloned() + .chain(self.runs.ordered_sessions()) + .chain(self.goals.admissions.ordered_sessions()) + .chain(self.mcp_projects.client_sessions()) + .filter(|active| active != id) + .collect::>() + }; + if self.has_parked_invocation(id).await? + || !self + .store + .session(id) + .await + .map_err(|_| "agent_store_unavailable")? + .message_queue(id) + .await + .map_err(|_| "agent_store_unavailable")? + .entries + .is_empty() + { + return Err("agent_checkpoint_busy".into()); + } + let mut after = None; + loop { + let pending = self + .store + .pending_message_sessions(&self.prefix, after.as_deref()) + .await + .map_err(|_| "agent_store_unavailable")?; + if pending.is_empty() { + break; + } + after = pending.last().cloned(); + active.extend(pending.into_iter().filter(|active| active != id)); + } + active.extend( + self.store + .ready_native_approval_sessions() + .await + .map_err(|_| "agent_store_unavailable")? + .into_iter() + .filter(|active| self.owns(active) && active != id), + ); + active.extend( + self.store + .due_model_retry_sessions(i64::MAX as u64) + .await + .map_err(|_| "agent_store_unavailable")? + .into_iter() + .filter(|active| self.owns(active) && active != id), + ); + for active in active { + if self.runs.checkpointed(&active) { + continue; + } + let Some(other) = self + .store + .get_session::(&active) + .await + .map_err(|_| "agent_store_unavailable")? + else { + continue; + }; + let other = resolve_session(other)?; + let other_path = std::path::Path::new(&other.configuration.workspace.path); + if paths.iter().any(|path| runs::overlaps(path, other_path)) { + return Err("agent_checkpoint_busy".into()); + } + } + Ok((admission, session)) + } +} + +impl Owner { + fn history_checkpoints(&self) -> CheckpointPublisher { + CheckpointPublisher::new( self.store.clone(), storage::Scope { state_domain: self.data_source.state_domain.clone(), @@ -65,27 +221,6 @@ impl Owner { .await } - pub async fn publish_history_checkpoint( - self: &Arc, - input: HistoryCheckpointPublish, - operation: &RecoveryRef, - scope: &Scope, - ) -> Result { - management(scope)?; - let operation = operation.clone(); - self.resource_work(scope, move |owner, _| { - Box::pin(async move { - owner - .history_checkpoints() - .publish(capture(input), &operation) - .await - .map(publication) - .map_err(failure) - }) - }) - .await - } - pub async fn history_checkpoint_head( self: &Arc, input: SessionRead, @@ -105,42 +240,6 @@ impl Owner { .await } - pub async fn history_checkpoint_receipt( - self: &Arc, - input: HistoryCheckpointReceiptRead, - context: Context, - ) -> Result { - management(context.scope())?; - let scope = context.scope().clone(); - self.resource_work(&scope, move |owner, _| { - Box::pin(async move { - let publisher = owner.history_checkpoints(); - let capture = capture(input.input); - // Validate domain identity before invoking the separately authorized Host lookup. - publisher - .validate_request(&capture, &input.reference) - .map_err(failure)?; - let operation = context.recover(input.reference.clone())?.await?; - let publication = if operation.is_some() { - publisher - .receipt(capture, &input.reference) - .await - .map_err(failure)? - .map(publication) - } else { - None - }; - // Host cleanup and a committed domain result are different facts. In - // particular a lookup must never manufacture an original DispatchClaim. - Ok(HistoryCheckpointReceipt { - operation, - publication, - }) - }) - }) - .await - } - pub async fn read_history_checkpoint( self: &Arc, input: HistoryCheckpointRead, @@ -155,7 +254,12 @@ impl Owner { .checkout_exact(&input.session_id, storage::Revision(input.revision)) .await .map_err(failure)?; - let storage::Manifest::NativeHistory { + let workspace = checkout + .manifest + .workspace() + .cloned() + .map(workspace_snapshot); + let storage::Manifest { inventory: members, bundle, .. @@ -165,10 +269,11 @@ impl Owner { &context, &owner, &scope, - HistoryCheckpointFrame::NativeHistoryV1 { + HistoryCheckpointFrame::Checkpoint { head: head(checkout.head), inventory: inventory(members), bundle: object(bundle), + workspace, }, ) .await?; @@ -222,7 +327,10 @@ impl Owner { } fn management(scope: &Scope) -> Result<(), String> { - if scope.identity.agent().is_some() { + if !matches!( + scope.identity, + maka_capabilities::call::Identity::Remote { .. } + ) { return Err("agent_history_checkpoint_user_action_required".into()); } Ok(()) @@ -240,11 +348,12 @@ async fn emit( .emit(serde_json::to_value(frame).map_err(|_| "agent_history_checkpoint_output_invalid")?) .await } -fn capture(input: HistoryCheckpointPublish) -> Capture { +fn capture(input: CheckpointPublish) -> Capture { Capture { session_id: input.session_id, expected_checkpoint_revision: input.expected_revision.map(storage::Revision), confirmed_subtree_digest: input.confirmed_subtree_digest, + workspace: None, } } fn object(value: storage::ObjectRef) -> HistoryCheckpointObject { @@ -253,6 +362,19 @@ fn object(value: storage::ObjectRef) -> HistoryCheckpointObject { bytes: value.bytes, } } +fn workspace_snapshot(w: storage::WorkspaceSnapshot) -> CheckpointWorkspaceSnapshot { + CheckpointWorkspaceSnapshot { + identity: w.identity, + inventory: object(w.inventory), + consistency: match w.consistency { + storage::WorkspaceConsistency::CallerConfirmedQuiescent => { + CheckpointWorkspaceConsistency::CallerConfirmedQuiescent + } + storage::WorkspaceConsistency::Unverified => CheckpointWorkspaceConsistency::Unverified, + }, + warning: CheckpointWorkspaceWarning::ExternalWritersNotFenced, + } +} fn head(value: storage::Head) -> HistoryCheckpointHead { HistoryCheckpointHead { revision: value.revision.0, @@ -273,7 +395,7 @@ fn inventory(value: maka_event_log::bundle::Inventory) -> HistoryCheckpointInven .collect(), } } -fn publication(value: storage::Publication) -> HistoryCheckpointPublication { +fn publication(value: storage::Publication) -> CheckpointPublication { let state = match value.state { storage::PublicationState::Capturing => HistoryCheckpointState::Capturing, storage::PublicationState::Prepared { .. } => HistoryCheckpointState::Prepared, @@ -286,9 +408,10 @@ fn publication(value: storage::Publication) -> HistoryCheckpointPublication { storage::PublicationState::Interrupted => HistoryCheckpointState::Interrupted, storage::PublicationState::Unavailable { .. } => HistoryCheckpointState::Unavailable, }; - HistoryCheckpointPublication { + CheckpointPublication { state, cleanup_pending: value.cleanup_pending, + workspace: None, } } fn failure(error: StoreError) -> String { diff --git a/plugins/agent/native/src/owner/checkpoints/load.rs b/plugins/agent/native/src/owner/checkpoints/load.rs new file mode 100644 index 00000000000..89431ad4258 --- /dev/null +++ b/plugins/agent/native/src/owner/checkpoints/load.rs @@ -0,0 +1,238 @@ +/* + * 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. + */ + +//! Host-admitted isolated inspection; no method here creates a runnable Session. +use super::*; + +impl Owner { + pub async fn load_history_checkpoint( + self: &Arc, + input: HistoryCheckpointRead, + operation: &RecoveryRef, + scope: &Scope, + ) -> Result { + management(scope)?; + let operation = operation.clone(); + self.resource_work(scope, move |owner, _| { + Box::pin(async move { + let publisher = owner.history_checkpoints(); + let request = publisher + .load_request(&input, &operation) + .map_err(failure)?; + let value = publisher + .repository(&input.session_id) + .await + .map_err(failure)? + .load_exact(request) + .await + .map_err(failure)?; + Ok(loaded( + value, + CheckpointInspection { + source_session_id: input.session_id, + operation_id: operation.operation, + }, + )) + }) + }) + .await + } + pub async fn history_checkpoint_load_receipt( + self: &Arc, + input: CheckpointLoadReceiptRead, + context: Context, + ) -> Result { + management(context.scope())?; + let scope = context.scope().clone(); + self.resource_work(&scope, move |owner, _| { + Box::pin(async move { + let publisher = owner.history_checkpoints(); + let request = publisher + .load_request(&input.input, &input.reference) + .map_err(failure)?; + let operation = context.recover(input.reference.clone())?.await?; + let load = if operation.is_some() { + publisher + .repository(&input.input.session_id) + .await + .map_err(failure)? + .load_receipt(request) + .await + .map_err(failure)? + .map(|value| { + loaded( + value, + CheckpointInspection { + source_session_id: input.input.session_id, + operation_id: input.reference.operation, + }, + ) + }) + } else { + None + }; + Ok(CheckpointLoadReceipt { operation, load }) + }) + }) + .await + } + pub async fn loaded_checkpoint_history( + self: &Arc, + input: CheckpointHistoryRead, + scope: &Scope, + ) -> Result { + management(scope)?; + self.resource_work(scope, move |owner, _| { + Box::pin(async move { + let page = owner + .history_checkpoints() + .repository(&input.inspection.source_session_id) + .await + .map_err(failure)? + .loaded_history( + input.inspection.operation_id, + input.session_id, + input.position, + input.include_thinking, + ) + .await + .map_err(failure)?; + Ok(HistoryPage::from(page)) + }) + }) + .await + } + pub async fn loaded_checkpoint_artifacts( + self: &Arc, + input: CheckpointArtifactsRead, + scope: &Scope, + ) -> Result { + management(scope)?; + self.resource_work(scope, move |owner, _| { + Box::pin(async move { + let page = owner + .history_checkpoints() + .repository(&input.inspection.source_session_id) + .await + .map_err(failure)? + .loaded_artifacts( + input.inspection.operation_id, + input.session_id, + input.offset, + ) + .await + .map_err(failure)?; + let next = input.offset + page.records.len() as u64; + Ok(CheckpointArtifacts { + next_offset: (next < page.total).then_some(next), + artifacts: page.records.into_iter().map(artifact).collect(), + }) + }) + }) + .await + } + pub async fn loaded_checkpoint_artifact( + self: &Arc, + input: CheckpointArtifactRead, + context: Context, + ) -> Result { + management(context.scope())?; + let scope = context.scope().clone(); + let context = Arc::new(context); + self.resource_work(&scope.clone(), move |owner, _| { + Box::pin(async move { + owner + .history_checkpoints() + .repository(&input.inspection.source_session_id) + .await + .map_err(failure)? + .stream_loaded_artifact( + input.inspection.operation_id, + input.session_id, + input.artifact_id, + input.offset, + move |record| { + let context = context.clone(); + let scope = scope.clone(); + let owner = owner.clone(); + async move { + if scope.cancellation.is_cancelled() || owner.stop.is_cancelled() { + return Err(maka_event_log::StoreError::InvalidTransition( + "checkpoint artifact cancelled".into(), + )); + } + let chunk = + record.map(|(record, offset, chunk)| CheckpointArtifactChunk { + artifact: artifact(record), + offset, + data: STANDARD.encode(chunk.bytes), + }); + context + .emit(serde_json::to_value(chunk)?) + .await + .map_err(maka_event_log::StoreError::InvalidTransition) + } + }, + ) + .await + .map_err(failure)?; + Ok(Value::Null) + }) + }) + .await + } +} +fn loaded(value: storage::LoadedCheckpoint, inspection: CheckpointInspection) -> CheckpointLoad { + CheckpointLoad { + head: head(value.head), + inspection, + state: match value.state { + storage::LoadState::Preparing => CheckpointLoadState::Preparing, + storage::LoadState::Ready { + session_ids, + workspace, + .. + } => CheckpointLoadState::Ready { + session_ids, + workspace: workspace.map(|w| CheckpointWorkspaceSnapshot { + identity: w.identity, + inventory: object(w.inventory), + consistency: match w.consistency { + storage::WorkspaceConsistency::CallerConfirmedQuiescent => { + CheckpointWorkspaceConsistency::CallerConfirmedQuiescent + } + storage::WorkspaceConsistency::Unverified => { + CheckpointWorkspaceConsistency::Unverified + } + }, + warning: CheckpointWorkspaceWarning::ExternalWritersNotFenced, + }), + }, + storage::LoadState::Unavailable => CheckpointLoadState::Unavailable, + }, + } +} +fn artifact(record: maka_agent_facts::artifact::Artifact) -> CheckpointArtifact { + CheckpointArtifact { + id: record.id, + name: record.name, + bytes: record.size_bytes, + mime_type: record.mime_type, + } +} diff --git a/plugins/agent/native/src/owner/checkpoints/restore.rs b/plugins/agent/native/src/owner/checkpoints/restore.rs new file mode 100644 index 00000000000..aa528980048 --- /dev/null +++ b/plugins/agent/native/src/owner/checkpoints/restore.rs @@ -0,0 +1,235 @@ +/* + * 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. + */ + +//! Explicit same-Root workspace recovery; loading never admits a model call. +use super::*; + +fn restored(value: storage::RestoredSession) -> SessionCheckpointRestored { + SessionCheckpointRestored { + operation: value.operation, + session_id: value.session_id, + session_revision: value.session_revision, + head: head(value.prepared.head), + workspace: workspace_snapshot(value.prepared.workspace), + path: value.prepared.path.to_string_lossy().into_owned(), + } +} +impl Owner { + async fn restore_request( + &self, + input: &SessionCheckpointRestore, + operation: &RecoveryRef, + ) -> Result<(maka_event_log::root_store::HistoryCheckpoints, String), String> { + operation.validate().map_err(|e| e.to_string())?; + if !self.owns(&input.checkpoint.session_id) + || operation.method != "restoreSessionCheckpoint" + || operation.export != maka_agent_api::EXPORT + || operation.source.state_domain != self.data_source.state_domain + || operation.source.deployment != self.data_source.deployment + || operation.namespace != self.data_source.namespace + { + return Err("agent_checkpoint_restore_authority_mismatch".into()); + } + let repository = self + .history_checkpoints() + .repository(&input.checkpoint.session_id) + .await + .map_err(failure)?; + let fingerprint = hex::encode(Sha256::digest( + serde_json::to_vec(operation).map_err(|e| e.to_string())?, + )); + Ok((repository, fingerprint)) + } + + pub async fn restore_session_checkpoint( + self: &Arc, + input: SessionCheckpointRestore, + operation: &RecoveryRef, + scope: &Scope, + ) -> Result { + management(scope)?; + let operation = operation.clone(); + self.resource_work(scope, move |owner, _| { + Box::pin(async move { + let (repository, fingerprint) = owner.restore_request(&input, &operation).await?; + if let Some(receipt) = repository + .workspace_restore_receipt( + operation.operation, + fingerprint.clone(), + storage::Revision(input.checkpoint.revision), + ) + .await + .map_err(failure)? + { + return Ok(restored(receipt)); + } + if !input.accept_external_writers_risk { + return Err("agent_checkpoint_external_writers_not_fenced".into()); + } + if !matches!(input.backend, BackendPreference::Model { .. }) { + return Err("agent_checkpoint_unsupported_session".into()); + } + if !owner.backend_available(&input.backend).await? { + return Err("agent_checkpoint_backend_unavailable".into()); + } + let root = owner.store.owned_root().map_err(failure)?; + let parent = workspace::observed( + owner.workspace_roots.clone(), + &input.parent, + owner.home.as_deref(), + root.canonical_path().to_owned(), + ) + .await? + .ok_or("agent_directory_unavailable")?; + let path = std::path::PathBuf::from(parent.path); + let control_dir = std::fs::canonicalize(root.control_directory()) + .unwrap_or_else(|_| root.control_directory().to_owned()); + if path.starts_with(&control_dir) + || path.starts_with(root.control_directory()) + || path.starts_with(&owner.managed_worktrees) + { + return Err("agent_directory_unavailable".into()); + } + let (admission, session) = owner + .checkpoint_admission(&input.checkpoint.session_id, Some(&path)) + .await?; + if owner.behavior.is_some() + || session.archived + || session.configuration.parent_session_id.is_some() + || session + .configuration + .backend + .as_ref() + .is_some_and(|v| !matches!(v, BackendPreference::Model { .. })) + || owner.graph_selected(&session.id).await? + || owner + .store + .session(&session.id) + .await + .map_err(|e| e.to_string())? + .session_manager(&session.id) + .await + .map_err(|_| "agent_session_unavailable")? + .is_some() + || owner + .goals + .repository + .read(&session.id) + .await + .map_err(|e| e.to_string())? + .is_some() + { + return Err("agent_checkpoint_unsupported_session".into()); + } + let request = storage::RestoreOperation { + operation: operation.operation, + fingerprint, + revision: storage::Revision(input.checkpoint.revision), + parent: path, + parent_identity: parent.identity, + }; + let prepared = repository + .prepare_workspace_restore(request.clone()) + .await + .map_err(failure)?; + let root = owner.store.owned_root().map_err(failure)?; + let control_dir = std::fs::canonicalize(root.control_directory()) + .unwrap_or_else(|_| root.control_directory().to_owned()); + let selected = workspace::initialize( + &owner.workspace_roots, + prepared + .path + .to_str() + .ok_or("agent_directory_unavailable")?, + owner.home.as_deref(), + &owner.managed_worktrees, + root.canonical_path(), + &control_dir, + ) + .await? + .ok_or("agent_workspace_unavailable")?; + let binding = owner + .store + .attach_workspace(&owner.prefix, &selected) + .await + .map_err(failure)?; + let permissions = input.permissions; + let backend = input.backend; + let result = repository + .commit_workspace_restore( + request, + now(), + move |configuration: &mut StoredSessionConfiguration| { + configuration.workspace = binding.clone(); + configuration.permissions = permissions.clone(); + configuration.backend = Some(backend.clone()); + configuration.boundary_revision = configuration + .boundary_revision + .checked_add(1) + .filter(|v| *v <= 9_007_199_254_740_991) + .ok_or_else(|| { + StoreError::InvalidTransition( + "permission boundary revision exhausted".into(), + ) + })?; + Ok(()) + }, + ) + .await + .map(restored) + .map_err(failure); + drop(admission); + result + }) + }) + .await + } + pub async fn session_checkpoint_restore_receipt( + self: &Arc, + input: SessionCheckpointRestoreReceiptRead, + context: Context, + ) -> Result { + management(context.scope())?; + let scope = context.scope().clone(); + self.resource_work(&scope, move |owner, _| { + Box::pin(async move { + let (repository, fingerprint) = owner + .restore_request(&input.input, &input.reference) + .await?; + let id = input.reference.operation; + let operation = context.recover(input.reference)?.await?; + let restore = if operation.is_some() { + repository + .workspace_restore_receipt( + id, + fingerprint, + storage::Revision(input.input.checkpoint.revision), + ) + .await + .map_err(failure)? + .map(restored) + } else { + None + }; + Ok(SessionCheckpointRestoreReceipt { operation, restore }) + }) + }) + .await + } +} diff --git a/plugins/agent/native/src/owner/checkpoints/session.rs b/plugins/agent/native/src/owner/checkpoints/session.rs new file mode 100644 index 00000000000..be222e9dd52 --- /dev/null +++ b/plugins/agent/native/src/owner/checkpoints/session.rs @@ -0,0 +1,191 @@ +/* + * 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. + */ + +//! Session history plus a caller-attested or explicitly unverified workspace copy. +use super::*; + +fn consistency(value: CheckpointWorkspaceConsistency) -> storage::WorkspaceConsistency { + match value { + CheckpointWorkspaceConsistency::CallerConfirmedQuiescent => { + storage::WorkspaceConsistency::CallerConfirmedQuiescent + } + CheckpointWorkspaceConsistency::Unverified => storage::WorkspaceConsistency::Unverified, + } +} +fn response( + value: storage::Publication, + mode: Option, +) -> CheckpointPublication { + let mut result = publication(value); + result.workspace = mode.map(|consistency| CheckpointWorkspaceNotice { + consistency, + warning: CheckpointWorkspaceWarning::ExternalWritersNotFenced, + }); + result +} +fn requested(input: CheckpointPublish) -> (Capture, Option) { + let mode = input.workspace_consistency; + let mut capture = capture(input); + capture.workspace = mode.map(|mode| storage::WorkspaceCapture { + root: Default::default(), + identity: String::new(), + consistency: consistency(mode), + }); + (capture, mode) +} + +impl Owner { + pub async fn publish_checkpoint( + self: &Arc, + input: CheckpointPublish, + operation: &RecoveryRef, + scope: &Scope, + ) -> Result { + management(scope)?; + let operation = operation.clone(); + self.resource_work(scope, move |owner, _| { + Box::pin(async move { + let publisher = owner.history_checkpoints(); + let (mut capture, mode) = requested(input); + let request = publisher.request(&capture, &operation).map_err(failure)?; + let repository = publisher + .repository(&capture.session_id) + .await + .map_err(failure)?; + if let Some(original) = repository + .original_request(request) + .await + .map_err(failure)? + { + return repository + .publish(original) + .await + .map(|p| response(p, mode)) + .map_err(failure); + } + let Some(workspace_mode) = mode else { + return publisher + .publish(capture, &operation) + .await + .map(|value| response(value, None)) + .map_err(failure); + }; + let (admission, session) = owner + .checkpoint_admission(&capture.session_id, None) + .await?; + if owner.behavior.is_some() + || session.configuration.parent_session_id.is_some() + || session + .configuration + .backend + .as_ref() + .is_some_and(|v| !matches!(v, BackendPreference::Model { .. })) + || owner.graph_selected(&session.id).await? + || owner + .store + .session(&session.id) + .await + .map_err(|e| e.to_string())? + .session_manager(&session.id) + .await + .map_err(|_| "agent_session_unavailable")? + .is_some() + { + return Err("agent_checkpoint_unsupported_session".into()); + } + let inventory = owner + .store + .preview_bundle(&capture.session_id) + .await + .map_err(|e| e.to_string())?; + if inventory.sessions.len() != 1 { + return Err("agent_checkpoint_multiple_workspaces_unsupported".into()); + } + workspace::validate( + &owner.workspace_roots, + &session.configuration.workspace, + &owner.managed_worktrees, + ) + .await + .map_err(|_| "agent_workspace_unavailable")?; + capture.workspace = Some(storage::WorkspaceCapture { + root: session.configuration.workspace.path.into(), + identity: session.configuration.workspace.identity.into(), + consistency: consistency(workspace_mode), + }); + let result = publisher + .publish(capture, &operation) + .await + .map(|p| response(p, mode)) + .map_err(failure); + drop(admission); + result + }) + }) + .await + } + + pub async fn checkpoint_receipt( + self: &Arc, + input: CheckpointReceiptRead, + context: Context, + ) -> Result { + management(context.scope())?; + let scope = context.scope().clone(); + self.resource_work(&scope, move |owner, _| { + Box::pin(async move { + let publisher = owner.history_checkpoints(); + let (capture, mode) = requested(input.input); + publisher + .validate_request(&capture, &input.reference) + .map_err(failure)?; + let request = publisher + .request(&capture, &input.reference) + .map_err(failure)?; + let operation = context.recover(input.reference)?.await?; + let publication = if operation.is_some() { + let repository = publisher + .repository(&capture.session_id) + .await + .map_err(failure)?; + if let Some(original) = repository + .original_request(request) + .await + .map_err(failure)? + { + repository + .receipt(original) + .await + .map_err(failure)? + .map(|p| response(p, mode)) + } else { + None + } + } else { + None + }; + Ok(CheckpointReceipt { + operation, + publication, + }) + }) + }) + .await + } +} diff --git a/plugins/agent/native/src/owner/compaction.rs b/plugins/agent/native/src/owner/compaction.rs index 112336b1741..09e1cd50299 100644 --- a/plugins/agent/native/src/owner/compaction.rs +++ b/plugins/agent/native/src/owner/compaction.rs @@ -58,7 +58,17 @@ impl Owner { &self, input: SetCompactionPrompt, ) -> Result { + if self.runs.checkpointed(&input.session_id) { + return Ok(MetadataResult::Rejected { + reason: Rejection::Busy, + }); + } let runs = self.runs.lock().await; + if self.runs.checkpointed(&input.session_id) { + return Ok(MetadataResult::Rejected { + reason: Rejection::Busy, + }); + } if runs.closing { return Ok(MetadataResult::Rejected { reason: Rejection::Closed, @@ -104,7 +114,7 @@ impl Owner { operation: &maka_plugin_core::operation::RecoveryRef, ) -> Result { let mut runs = self.runs.lock().await; - let session = self.session(&input.session_id).await?; + let session = self.mutable_session(&input.session_id).await?; let identity = self.operation_id(operation); let invocation = Invocation { session_id: input.session_id.clone(), diff --git a/plugins/agent/native/src/owner/execution.rs b/plugins/agent/native/src/owner/execution.rs index 2e1cbb11273..f8871f3ffb8 100644 --- a/plugins/agent/native/src/owner/execution.rs +++ b/plugins/agent/native/src/owner/execution.rs @@ -400,6 +400,12 @@ impl Owner { .session(&source.session_id) .await .map_err(RunError::Internal)?; + if !self.runs.workspace_available( + &source.session_id, + std::path::Path::new(&session.configuration.workspace.path), + ) { + return Ok(()); + } drop(runs); let store = self .store @@ -449,6 +455,10 @@ impl Owner { return Err(RunError::Cancelled); } if runs.busy(&source.session_id) + || !self.runs.workspace_available( + &source.session_id, + std::path::Path::new(¤t.configuration.workspace.path), + ) || self .has_parked_invocation(&source.session_id) .await @@ -527,6 +537,10 @@ impl Owner { return Ok(ResumePreparation::Unavailable(Reason::Archived)); } if runs.busy(&input.session_id) + || !self.runs.workspace_available( + &input.session_id, + std::path::Path::new(&session.configuration.workspace.path), + ) || self.has_parked_invocation(&input.session_id).await? || !self .store diff --git a/plugins/agent/native/src/owner/goal.rs b/plugins/agent/native/src/owner/goal.rs index 41159c7842b..d72edb3a425 100644 --- a/plugins/agent/native/src/owner/goal.rs +++ b/plugins/agent/native/src/owner/goal.rs @@ -31,6 +31,7 @@ use std::sync::{ use tokio::sync::Notify; pub(super) struct Goals { + pub(super) admissions: Arc, storage: Arc, pub(super) repository: Repository, pub(super) gate: Mutex<()>, @@ -50,6 +51,7 @@ impl Goals { .expect("goal namespace"), )); Self { + admissions: Arc::new(runs::Registry::default()), repository: Repository::new(storage.clone()), storage, gate: Mutex::new(()), @@ -275,6 +277,15 @@ impl Owner { if runs.busy(session) { return Ok(false); } + if self.runs.has_checkpoints() { + let current = self.session(session).await?; + if !self.runs.workspace_available( + session, + std::path::Path::new(¤t.configuration.workspace.path), + ) { + return Ok(false); + } + } if !self .store .session(session) @@ -312,13 +323,15 @@ impl Owner { session_id } }; - self.session(session).await?; + let admission = self.goals.admissions.ordered(session).await; + self.mutable_session(session).await?; let runs = self.runs.lock().await; if runs.closing || self.stop.is_cancelled() { return Err("agent_closed".into()); } let owner = self.clone(); let job = self.workers.spawn(async move { + let _admission = admission; let _gate = owner.goals.gate.lock().await; if let Some(result) = owner.goals.receipt(&operation).await? { return Ok(result); diff --git a/plugins/agent/native/src/owner/graph.rs b/plugins/agent/native/src/owner/graph.rs index a526d781a77..36c8d1bded2 100644 --- a/plugins/agent/native/src/owner/graph.rs +++ b/plugins/agent/native/src/owner/graph.rs @@ -332,7 +332,7 @@ impl Owner { if runs.closing || owner.stop.is_cancelled() { return Err("agent_closed".into()); } - let session = owner.session(&input.session_id).await?; + let session = owner.mutable_session(&input.session_id).await?; let current = graphs.current(&input.session_id).await?; if current.as_ref().map(|c| &c.epoch.graph_id) != input.expected_graph_id.as_ref() { return graphs diff --git a/plugins/agent/native/src/owner/mcp/session.rs b/plugins/agent/native/src/owner/mcp/session.rs index 5bdb1881c93..f0302559bfc 100644 --- a/plugins/agent/native/src/owner/mcp/session.rs +++ b/plugins/agent/native/src/owner/mcp/session.rs @@ -77,7 +77,7 @@ impl Owner { if owner.stop.is_cancelled() || context.scope().cancellation.is_cancelled() { return Err("agent_session_mcp_cancelled".into()); } - let current = owner.session(&input.session_id).await?; + let current = owner.mutable_session(&input.session_id).await?; if current.archived || current.configuration.workspace != workspace { return Err("agent_session_unavailable".into()); } @@ -138,7 +138,7 @@ impl Owner { return Err("agent_session_busy".into()); } } - let current = self.session(session).await?; + let current = self.mutable_session(session).await?; if current.archived || self.has_parked_invocation(session).await? { return Err("agent_session_unavailable".into()); } diff --git a/plugins/agent/native/src/owner/notifications.rs b/plugins/agent/native/src/owner/notifications.rs index 7bc7a1f4d49..ae5bb9937f1 100644 --- a/plugins/agent/native/src/owner/notifications.rs +++ b/plugins/agent/native/src/owner/notifications.rs @@ -103,6 +103,10 @@ impl Owner { Err(error) => return Err(error), }; if session.archived + || !self.runs.workspace_available( + &source.session_id, + std::path::Path::new(&session.configuration.workspace.path), + ) || session.configuration.workspace != route.workspace || session.configuration.binding != route.binding { @@ -133,6 +137,10 @@ impl Owner { } let current = self.session(&source.session_id).await?; if runs.busy(&source.session_id) + || !self.runs.workspace_available( + &source.session_id, + std::path::Path::new(¤t.configuration.workspace.path), + ) || self.has_parked_invocation(&source.session_id).await? || current.configuration != session.configuration || current.archived diff --git a/plugins/agent/native/src/owner/patches.rs b/plugins/agent/native/src/owner/patches.rs index c32b840edd3..13e74d64eb7 100644 --- a/plugins/agent/native/src/owner/patches.rs +++ b/plugins/agent/native/src/owner/patches.rs @@ -131,7 +131,12 @@ impl Owner { if let Some(patch) = self.patch_record(&execution, binding.base_commit()).await? { return Ok(PatchResult::Ready { patch }); } - if runs.closing || !ended(&execution.state) { + if runs.closing + || !ended(&execution.state) + || !self + .runs + .workspace_available(&session.id, std::path::Path::new(&workspace.path)) + { return Ok(PatchResult::Busy); } if self @@ -210,6 +215,9 @@ impl Owner { if !self.owns(&member.session_id) || member.orphaned_shells || runs.busy(&member.session_id) + || !self + .runs + .workspace_available(&member.session_id, std::path::Path::new(path)) || self .store .session(&member.session_id) diff --git a/plugins/agent/native/src/owner/projects.rs b/plugins/agent/native/src/owner/projects.rs index a7dc790ba96..3bc9c9561be 100644 --- a/plugins/agent/native/src/owner/projects.rs +++ b/plugins/agent/native/src/owner/projects.rs @@ -432,6 +432,18 @@ impl Owner { if other.as_ref().map(reference) != destination { return Ok(ProjectResult::Changed); } + if current + .locations + .iter() + .chain(other.iter().flat_map(|p| &p.locations)) + .any(|location| { + !self + .runs + .workspace_available("", std::path::Path::new(&location.path)) + }) + { + return Ok(ProjectResult::Busy); + } let ids = std::iter::once(current.id.clone()) .chain(current.aliases.iter().cloned()) .chain(other.iter().flat_map(|record| { diff --git a/plugins/agent/native/src/owner/queue.rs b/plugins/agent/native/src/owner/queue.rs index a28fef0d42d..3ba2dfcffb2 100644 --- a/plugins/agent/native/src/owner/queue.rs +++ b/plugins/agent/native/src/owner/queue.rs @@ -53,7 +53,7 @@ impl maka_agent_engine::NativeApprovalExecutor for Owner { > { Box::pin(async move { let session = self - .session(&pending.invocation.session_id) + .mutable_session(&pending.invocation.session_id) .await .map_err(maka_agent_facts::tools::ToolError::Failed)?; workspace::validate( @@ -166,6 +166,15 @@ impl Owner { if !self.owns(&session) { continue; } + if self.runs.has_checkpoints() + && let Ok(current) = self.session(&session).await + && !self.runs.workspace_available( + &session, + std::path::Path::new(¤t.configuration.workspace.path), + ) + { + continue; + } let mut runs = self.runs.state().await; if runs.closing || self.stop.is_cancelled() { return Ok(()); @@ -200,7 +209,7 @@ impl Owner { } // Recover the owner-scoped durable backlog without limiting active Sessions. - async fn schedule(self: &Arc) { + pub(super) async fn schedule(self: &Arc) { if let Err(error) = self.schedule_pending().await && !(self.stop.is_cancelled() && matches!( @@ -244,6 +253,15 @@ impl Owner { { continue; } + if self.runs.has_checkpoints() + && let Ok(current) = self.session(session).await + && !self.runs.workspace_available( + session, + std::path::Path::new(¤t.configuration.workspace.path), + ) + { + continue; + } let mut runs = self.runs.state().await; if runs.closing || self.stop.is_cancelled() { return Ok(()); @@ -490,6 +508,23 @@ impl Owner { if cancellation.is_cancelled() { return Ok(None); } + if self.runs.has_checkpoints() { + if self.runs.checkpointed(session) { + return Ok(None); + } + if let Some(record) = self + .store + .get_session::(session) + .await? + && let Ok(resolved) = resolve_session(record) + && !self.runs.workspace_available( + session, + std::path::Path::new(&resolved.configuration.workspace.path), + ) + { + return Ok(None); + } + } let store = self.store.session(session).await?; if let Some(checkpoint) = store.activation_checkpoint(session).await? { return match &checkpoint.checkpoint.suspension { diff --git a/plugins/agent/native/src/owner/runs.rs b/plugins/agent/native/src/owner/runs.rs index f21a9e26694..45d74ad17e0 100644 --- a/plugins/agent/native/src/owner/runs.rs +++ b/plugins/agent/native/src/owner/runs.rs @@ -21,6 +21,7 @@ use super::Runs; use std::{ collections::BTreeMap, ops::{Deref, DerefMut}, + path::{Path, PathBuf}, sync::{Arc, Mutex as MapMutex, Weak}, }; use tokio::sync::{ @@ -30,14 +31,84 @@ use tokio::sync::{ /// Lifecycle and control mutations exclude submissions. Submissions to distinct /// Sessions share that lifecycle boundary, but never their per-Session ordering. -#[derive(Default)] pub(super) struct Registry { activity: Arc>, state: Arc>, sessions: MapMutex>>, + checkpoints: Arc>>>, +} + +impl Default for Registry { + fn default() -> Self { + let checkpoints = Arc::new(MapMutex::new(BTreeMap::new())); + Self { + activity: Default::default(), + state: Arc::new(Mutex::new(Runs { + checkpoints: checkpoints.clone(), + ..Runs::default() + })), + sessions: Default::default(), + checkpoints, + } + } +} + +pub(super) fn overlaps(a: &Path, b: &Path) -> bool { + a.starts_with(b) || b.starts_with(a) +} + +pub(super) struct Checkpoint { + checkpoints: Arc>>>, + session: String, +} +impl Drop for Checkpoint { + fn drop(&mut self) { + self.checkpoints.lock().unwrap().remove(&self.session); + } } impl Registry { + pub(super) fn ordered_sessions(&self) -> Vec { + self.sessions + .lock() + .unwrap() + .iter() + .filter(|(_, gate)| gate.strong_count() != 0) + .map(|(id, _)| id.clone()) + .collect() + } + pub(super) fn has_checkpoints(&self) -> bool { + !self.checkpoints.lock().unwrap().is_empty() + } + pub(super) fn checkpointed(&self, session: &str) -> bool { + self.checkpoints.lock().unwrap().contains_key(session) + } + pub(super) fn workspace_available(&self, session: &str, path: &Path) -> bool { + self.checkpoints.lock().unwrap().iter().all(|(id, active)| { + id != session && active.iter().all(|reserved| !overlaps(path, reserved)) + }) + } + pub(super) fn checkpoint( + &self, + session: &str, + paths: Vec, + ) -> Result { + let mut checkpoints = self.checkpoints.lock().unwrap(); + if checkpoints.contains_key(session) + || checkpoints.values().any(|active| { + active + .iter() + .any(|reserved| paths.iter().any(|path| overlaps(path, reserved))) + }) + { + return Err("agent_checkpoint_busy".into()); + } + checkpoints.insert(session.to_owned(), paths); + Ok(Checkpoint { + checkpoints: self.checkpoints.clone(), + session: session.to_owned(), + }) + } pub(super) async fn state(&self) -> MutexGuard<'_, Runs> { self.state.lock().await } diff --git a/plugins/agent/native/src/owner/scheduler/dispatch.rs b/plugins/agent/native/src/owner/scheduler/dispatch.rs index c82155cb403..915bd34e0a3 100644 --- a/plugins/agent/native/src/owner/scheduler/dispatch.rs +++ b/plugins/agent/native/src/owner/scheduler/dispatch.rs @@ -112,7 +112,7 @@ impl Owner { if self.graph_selected(id).await? { return Err("graph_background_conflict".into()); } - let session = self.session(id).await?; + let session = self.mutable_session(id).await?; if session.configuration.workspace != target.workspace || session.configuration.permissions != target.permissions || session.configuration.boundary_revision != target.boundary_revision @@ -155,7 +155,7 @@ impl Owner { if runs.closing || self.stop.is_cancelled() { return Err("agent_closed".into()); } - let current = self.session(id).await?; + let current = self.mutable_session(id).await?; if current.configuration != session.configuration || current.archived { return Err("scheduler_authorization_changed".into()); } diff --git a/plugins/agent/native/src/owner/tests.rs b/plugins/agent/native/src/owner/tests.rs index 1becff2640b..26e536dbdb5 100644 --- a/plugins/agent/native/src/owner/tests.rs +++ b/plugins/agent/native/src/owner/tests.rs @@ -534,6 +534,22 @@ impl Fixture { ) -> Self { Self::configured_provider(base, model, peer, tooling, configure, "openai-compatible").await } + async fn configured_with_temp_home( + base: &str, + configure: impl FnOnce(&mut Configuration), + ) -> Self { + Self::configured_application( + base, + None, + None, + None, + configure, + "openai-compatible", + FixtureApplication::Agent, + true, + ) + .await + } async fn configured_provider( base: &str, model: Option, @@ -550,6 +566,7 @@ impl Fixture { configure, provider_name, FixtureApplication::Agent, + false, ) .await } @@ -569,9 +586,11 @@ impl Fixture { configure, "openai-compatible", FixtureApplication::Workhub(setup), + false, ) .await } + #[allow(clippy::too_many_arguments)] async fn configured_application( base: &str, model: Option, @@ -580,6 +599,7 @@ impl Fixture { configure: impl FnOnce(&mut Configuration), provider_name: &str, application: FixtureApplication, + use_temp_home: bool, ) -> Self { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("root"); @@ -708,6 +728,7 @@ impl Fixture { FixtureApplication::Agent => { let capture = captured.clone(); let replacement = Arc::new(StdMutex::new(model)); + let temp_home = use_temp_home.then(|| dir.path().to_path_buf()); // Actual Host NativeContext and original NativeOwner; only the model // implementation is controlled in failure tests, never Root/admission. native @@ -717,10 +738,14 @@ impl Fixture { let capture = capture.clone(); let catalog = catalog.clone(); let replacement = replacement.lock().unwrap().take(); + let temp_home = temp_home.clone(); Box::pin(async move { let services = context.services(); let mut owner = Owner::open(context, configuration, catalog).await.unwrap(); + if let Some(home) = temp_home { + Arc::get_mut(&mut owner).unwrap().home = Some(home); + } if let Some(model) = replacement { Arc::get_mut(&mut owner).unwrap().engine = Engine::new( model, diff --git a/plugins/agent/native/src/owner/tests/checkpoints.rs b/plugins/agent/native/src/owner/tests/checkpoints.rs index 6ed718f7f6f..b0f6b31093f 100644 --- a/plugins/agent/native/src/owner/tests/checkpoints.rs +++ b/plugins/agent/native/src/owner/tests/checkpoints.rs @@ -56,14 +56,14 @@ async fn input(f: &Fixture, session: &str, expected: Value) -> Value { .await .unwrap(); assert_eq!(preview["rootSessionId"], session); - json!({"sessionId":session,"expectedRevision":expected,"confirmedSubtreeDigest":preview["subtreeDigest"]}) + json!({"sessionId":session,"expectedRevision":expected,"confirmedSubtreeDigest":preview["subtreeDigest"],"workspaceConsistency":null}) } async fn publish(f: &Fixture, input: Value, operation: Uuid) -> (RecoveryRef, Receipt) { let client = f.app.client(); let prepared = client .prepare_mutation( f.binding(2).await, - "publishHistoryCheckpoint".into(), + "publishCheckpoint".into(), input, scope(&f.issuer, true), operation, @@ -108,7 +108,7 @@ async fn read( let mut ended = false; while let Some(frame) = stream.recv().await { match frame["kind"].as_str().unwrap() { - "native_history_v1" => { + "checkpoint" => { assert!(begin.is_null()); begin = frame; } @@ -148,6 +148,10 @@ async fn turn(f: &Fixture, listener: &TcpListener, session: &str, text: &str) { } #[path = "checkpoints/benchmark.rs"] mod benchmark; +#[path = "checkpoints/load.rs"] +mod load; +#[path = "checkpoints/workspace.rs"] +mod workspace; async fn close(f: &Fixture) { f.app.shutdown().await.unwrap(); @@ -190,7 +194,7 @@ async fn host_publication_preserves_original_bytes_after_live_history_and_head_m turn(&f, &listener, &session, "after checkpoint").await; let observation = f .query( - "historyCheckpointReceipt", + "checkpointReceipt", json!({"reference":reference,"input":original}), ) .await @@ -228,7 +232,7 @@ async fn host_publication_preserves_original_bytes_after_live_history_and_head_m ); let receipt = f .query( - "historyCheckpointReceipt", + "checkpointReceipt", json!({"reference":reference,"input":original}), ) .await @@ -253,7 +257,7 @@ async fn busy_source_is_not_queued_or_republished_by_receipt_lookup() { let (reference,receipt)=publish(&f,original.clone(),operation).await; assert!(matches!(receipt,Receipt::Recorded {outcome:OperationOutcome::Unknown {ref diagnostic},..} if diagnostic=="agent_history_checkpoint_busy")); response(&mut socket).await; f.drained().await; - let receipt=f.query("historyCheckpointReceipt",json!({"reference":reference,"input":original})).await.unwrap(); + let receipt=f.query("checkpointReceipt",json!({"reference":reference,"input":original})).await.unwrap(); assert_eq!(receipt["publication"]["state"]["kind"],"interrupted"); assert!(f.query("historyCheckpointHead",json!({"sessionId":session})).await.unwrap().is_null()); let current=input(&f,&session,Value::Null).await; @@ -272,7 +276,7 @@ async fn host_policy_identity_original_fingerprint_and_close_are_enforced() { .client() .prepare_mutation( f.binding(2).await, - "publishHistoryCheckpoint".into(), + "publishCheckpoint".into(), original.clone(), scope(&f.issuer, false), Uuid::new_v4(), @@ -297,14 +301,14 @@ async fn host_policy_identity_original_fingerprint_and_close_are_enforced() { changed["expectedRevision"] = json!(Uuid::new_v4()); assert!( f.query( - "historyCheckpointReceipt", + "checkpointReceipt", json!({"reference":reference,"input":changed}) ) .await .is_err() ); assert!( - f.mutation(2, "publishHistoryCheckpoint", changed, operation) + f.mutation(2, "publishCheckpoint", changed, operation) .await .is_err() ); @@ -312,7 +316,7 @@ async fn host_policy_identity_original_fingerprint_and_close_are_enforced() { foreign.namespace = id(99); assert!( f.query( - "historyCheckpointReceipt", + "checkpointReceipt", json!({"reference":foreign,"input":original}) ) .await @@ -322,7 +326,7 @@ async fn host_policy_identity_original_fingerprint_and_close_are_enforced() { absent.operation = Uuid::new_v4(); let result = f .query( - "historyCheckpointReceipt", + "checkpointReceipt", json!({"reference":absent,"input":original}), ) .await @@ -354,7 +358,7 @@ async fn root_reopen_disambiguates_domain_success_without_fabricating_host_clean // A real SQL failure after the separate checkpoint transaction commits. // Install in every possible Host operation partition, not the Session partition. for path in f.runtime_databases() { - rusqlite::Connection::open(path).unwrap().execute_batch("CREATE TRIGGER checkpoint_host_failure BEFORE UPDATE ON application_operations WHEN json_extract(OLD.request_json,'$.method')='publishHistoryCheckpoint' BEGIN SELECT RAISE(ABORT,'fixture lost Host settlement'); END;").unwrap(); + rusqlite::Connection::open(path).unwrap().execute_batch("CREATE TRIGGER checkpoint_host_failure BEFORE UPDATE ON application_operations WHEN json_extract(OLD.request_json,'$.method')='publishCheckpoint' BEGIN SELECT RAISE(ABORT,'fixture lost Host settlement'); END;").unwrap(); } let (reference,receipt)=publish(&f,original.clone(),Uuid::new_v4()).await; assert!(matches!(receipt,Receipt::Unavailable {..}),"{receipt:?}"); @@ -374,7 +378,7 @@ async fn root_reopen_disambiguates_domain_success_without_fabricating_host_clean provide_all(&mut registry).unwrap(); let app=Application::start_from_store(store.clone(),deployment,registry,issuer.clone(),Arc::new(Grant(issuer.clone(),None)),Some(maka_host_js::factory(Default::default()))).await.unwrap(); app.ready().await.unwrap(); - let receipt=query(&app,&issuer,"historyCheckpointReceipt",json!({"reference":reference,"input":original})).await.unwrap(); + let receipt=query(&app,&issuer,"checkpointReceipt",json!({"reference":reference,"input":original})).await.unwrap(); assert_eq!(receipt["publication"]["state"],json!({"kind":"published","head":head})); assert_eq!(receipt["operation"]["outcome"]["kind"],"unresolved"); assert_eq!(receipt["operation"]["cleanup"]["kind"],"unconfirmed"); @@ -422,7 +426,7 @@ async fn cancellation_drains_bounded_reader_and_corruption_never_emits_success() .await .unwrap(); let first = stream.recv().await.unwrap(); - assert_eq!(first["kind"], "native_history_v1"); + assert_eq!(first["kind"], "checkpoint"); stream.cancel(); let completion = stream.completion().await; assert!(completion.result.is_err()); @@ -509,7 +513,7 @@ async fn model_scope_and_missing_recovery_grant_cannot_read_checkpoint_receipts( workspaces::publish_with_policy(&mut f, roots, policy).await; assert_eq!( f.query( - "historyCheckpointReceipt", + "checkpointReceipt", json!({"reference":reference,"input":original}) ) .await diff --git a/plugins/agent/native/src/owner/tests/checkpoints/benchmark.rs b/plugins/agent/native/src/owner/tests/checkpoints/benchmark.rs index b9364e300e5..70d445b0eb6 100644 --- a/plugins/agent/native/src/owner/tests/checkpoints/benchmark.rs +++ b/plugins/agent/native/src/owner/tests/checkpoints/benchmark.rs @@ -193,7 +193,7 @@ async fn history_checkpoint_baseline() { while let Some(frame) = stream.recv().await { first_ms.get_or_insert(time.elapsed().as_secs_f64() * 1000.); match frame["kind"].as_str().unwrap() { - "native_history_v1" => {} + "checkpoint" => {} "chunk" => { assert_eq!(frame["offset"], total); let chunk = STANDARD.decode(frame["data"].as_str().unwrap()).unwrap(); diff --git a/plugins/agent/native/src/owner/tests/checkpoints/load.rs b/plugins/agent/native/src/owner/tests/checkpoints/load.rs new file mode 100644 index 00000000000..d33ff5e4207 --- /dev/null +++ b/plugins/agent/native/src/owner/tests/checkpoints/load.rs @@ -0,0 +1,217 @@ +/* + * 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. + */ + +use super::*; + +async fn load(f: &Fixture, input: Value, operation: Uuid) -> (RecoveryRef, Receipt) { + let client = f.app.client(); + let prepared = client + .prepare_mutation( + f.binding(2).await, + "loadHistoryCheckpoint".into(), + input, + scope(&f.issuer, true), + operation, + Attachments::new().into(), + ) + .await + .unwrap(); + let reference = prepared.reference().clone(); + let result = client.mutate_owned(prepared).await.unwrap().result().await; + (reference, result.receipt) +} +fn loaded(receipt: Receipt) -> Value { + let Receipt::Recorded { + outcome: OperationOutcome::Returned { value }, + .. + } = receipt + else { + panic!("{receipt:?}"); + }; + assert_eq!(value["state"]["kind"], "ready"); + value +} + +#[tokio::test] +async fn exact_load_reads_frozen_history_and_artifacts_without_changing_live_session() { + tokio::time::timeout(Duration::from_secs(90),async { + let listener=TcpListener::bind("127.0.0.1:0").await.unwrap(); + let f=Fixture::new(&format!("http://{}/v1",listener.local_addr().unwrap()),None).await; + let session=f.create("Load checkpoint").await; + let bytes=vec![73;65539]; + attachments::upload(&f,&session,"before.txt",&bytes,Uuid::new_v4()).await.unwrap(); + turn(&f,&listener,&session,"frozen response").await; + let before = f.query("history", json!({"sessionId":session,"position":{"kind":"start"}})).await.unwrap(); + let chunks = before["chunks"].as_array().unwrap(); + assert!(!chunks.is_empty()); + assert!(chunks.iter().all(|chunk| chunk["order"]["sourceSequence"].is_u64())); + let checkpoint=published(publish(&f,input(&f,&session,Value::Null).await,Uuid::new_v4()).await.1); + let head=checkpoint["state"]["head"].clone(); + turn(&f,&listener,&session,"later live response").await; + attachments::upload(&f,&session,"after.txt",b"only live",Uuid::new_v4()).await.unwrap(); + let original=json!({"sessionId":session,"revision":head["revision"]}); + let (reference,receipt)=load(&f,original.clone(),Uuid::new_v4()).await; + let result=loaded(receipt); + assert_eq!(result["head"],head); + let inspection=result["inspection"].clone(); + let page=f.query("loadedHistoryCheckpointHistory",json!({"inspection":inspection,"sessionId":session,"position":{"kind":"start"}})).await.unwrap(); + assert_eq!(page["chunks"], before["chunks"]); + let text=page.to_string();assert!(text.contains("frozen response"));assert!(!text.contains("later live response")); + let live=f.query("history",json!({"sessionId":session,"position":{"kind":"tail"}})).await.unwrap(); + assert!(live.to_string().contains("later live response")); + let artifacts=f.query("loadedHistoryCheckpointArtifacts",json!({"inspection":inspection,"sessionId":session,"offset":0})).await.unwrap(); + assert_eq!(artifacts["artifacts"].as_array().unwrap().len(),1); + let artifact=&artifacts["artifacts"][0];assert_eq!(artifact["name"],"before.txt"); + let mut actual=Vec::new(); + let mut stream = f.app.client().subscribe(f.binding(2).await, + "loadedHistoryCheckpointArtifact".into(), + json!({"inspection":inspection,"sessionId":session,"artifactId":artifact["id"],"offset":0}), + scope(&f.issuer,true), Attachments::new().into()).await.unwrap(); + while let Some(chunk) = stream.recv().await { + assert_eq!(chunk["offset"], actual.len()); + let chunk=STANDARD.decode(chunk["data"].as_str().unwrap()).unwrap(); + assert!(chunk.len()<=32768); actual.extend(chunk); + } + let completion = stream.completion().await; + completion.cleanup.unwrap(); + assert_eq!(completion.result.unwrap(), Value::Null); + assert_eq!(actual,bytes); + let current=published(publish(&f,input(&f,&session,head["revision"].clone()).await,Uuid::new_v4()).await.1); + assert_ne!(current["state"]["head"],head); + assert_eq!(f.query("historyCheckpointLoadReceipt",json!({"reference":reference,"input":original})).await.unwrap()["load"],result); + assert_eq!(loaded(load(&f,original.clone(),reference.operation).await.1),result); + assert!(f.mutation(2,"loadHistoryCheckpoint",original,Uuid::new_v4()).await.is_err()); + let page=f.query("loadedHistoryCheckpointHistory",json!({"inspection":inspection,"sessionId":session,"position":{"kind":"start"}})).await.unwrap(); + assert!(page.to_string().contains("frozen response")); + let live_after=f.query("history",json!({"sessionId":session,"position":{"kind":"tail"}})).await.unwrap(); + assert_eq!(live_after,live); + close(&f).await; + }).await.unwrap(); +} + +#[tokio::test] +async fn inspection_rejects_foreign_members_namespaces_model_scope_and_changed_recovery_input() { + let f = Fixture::new("http://127.0.0.1:1/v1", None).await; + let session = f.create("Inspection owner").await; + let other = f.create("Unrelated live session").await; + let checkpoint = published( + publish(&f, input(&f, &session, Value::Null).await, Uuid::new_v4()) + .await + .1, + ); + let original = json!({"sessionId":session,"revision":checkpoint["state"]["head"]["revision"]}); + let (reference, receipt) = load(&f, original.clone(), Uuid::new_v4()).await; + let result = loaded(receipt); + let inspection = result["inspection"].clone(); + assert!( + f.query( + "loadedHistoryCheckpointHistory", + json!({"inspection":inspection,"sessionId":other,"position":{"kind":"tail"}}) + ) + .await + .is_err() + ); + let mut foreign = inspection.clone(); + foreign["sourceSessionId"] = json!("unowned"); + assert!( + f.query( + "loadedHistoryCheckpointArtifacts", + json!({"inspection":foreign,"sessionId":session,"offset":0}) + ) + .await + .is_err() + ); + let mut changed = original.clone(); + changed["revision"] = json!(Uuid::new_v4()); + assert!( + f.query( + "historyCheckpointLoadReceipt", + json!({"reference":reference,"input":changed}) + ) + .await + .is_err() + ); + let model = f + .issuer + .issue_with( + Identity::Agent { + invocation: maka_agent_facts::event::Invocation { + session_id: session.clone(), + turn_id: "t".into(), + run_id: "r".into(), + invocation_id: "i".into(), + }, + operation_id: None, + }, + true, + CancellationToken::new(), + ) + .unwrap(); + let error = f + .app + .client() + .start( + f.binding(2).await, + "loadedHistoryCheckpointArtifacts".into(), + json!({"inspection":inspection,"sessionId":session,"offset":0}), + model, + Attachments::new().into(), + ) + .await + .unwrap() + .result() + .await + .unwrap_err(); + assert_eq!(error, "agent_history_checkpoint_user_action_required"); + close(&f).await; +} + +#[tokio::test] +async fn loaded_inspection_survives_root_reopen_and_lost_host_settlement() { + tokio::time::timeout(Duration::from_secs(90),async { + let f=Fixture::new("http://127.0.0.1:1/v1",None).await; + let session=f.create("Loaded before Host settlement").await; + attachments::upload(&f,&session,"kept.txt",b"persisted inspection",Uuid::new_v4()).await.unwrap(); + let checkpoint=published(publish(&f,input(&f,&session,Value::Null).await,Uuid::new_v4()).await.1); + let original=json!({"sessionId":session,"revision":checkpoint["state"]["head"]["revision"]}); + for path in f.runtime_databases() { + rusqlite::Connection::open(path).unwrap().execute_batch("CREATE TRIGGER reject_load_host_settlement BEFORE UPDATE ON application_operations WHEN json_extract(OLD.request_json,'$.method')='loadHistoryCheckpoint' BEGIN SELECT RAISE(ABORT,'lost load settlement'); END;").unwrap(); + } + let (reference,receipt)=load(&f,original.clone(),Uuid::new_v4()).await; + assert!(matches!(receipt,Receipt::Unavailable{..})); + let observed=f.query("historyCheckpointLoadReceipt",json!({"reference":reference,"input":original})).await.unwrap(); + assert_eq!(observed["load"]["state"]["kind"],"ready"); + let inspection=observed["load"]["inspection"].clone(); + for path in f.runtime_databases(){rusqlite::Connection::open(path).unwrap().execute_batch("DROP TRIGGER reject_load_host_settlement").unwrap();} + let deployment=f.app.source().unwrap().deployment; + close(&f).await; + let Fixture{_dir,namespaces,root,store,config,app,issuer,owner,services,..}=f; + drop(app);drop(owner);drop(services);drop(config); + Arc::try_unwrap(store).ok().expect("all Root owners released").close().await.unwrap(); + let store=Arc::new(RootStore::open(Arc::new(RootOwner::open_existing(&root,&namespaces).unwrap())).await.unwrap()); + let mut registry=NativeRegistry::default();maka_settings_native::provide(&mut registry,bundled_catalog()).unwrap();provide_all(&mut registry).unwrap(); + let app=Application::start_from_store(store.clone(),deployment,registry,issuer.clone(),Arc::new(Grant(issuer.clone(),None)),Some(maka_host_js::factory(Default::default()))).await.unwrap();app.ready().await.unwrap(); + let observed=query(&app,&issuer,"historyCheckpointLoadReceipt",json!({"reference":reference,"input":original})).await.unwrap(); + assert_eq!(observed["load"]["state"]["kind"],"ready"); + assert_eq!(observed["operation"]["outcome"]["kind"],"unresolved"); + assert_eq!(observed["operation"]["cleanup"]["kind"],"unconfirmed"); + let artifacts=query(&app,&issuer,"loadedHistoryCheckpointArtifacts",json!({"inspection":inspection,"sessionId":session,"offset":0})).await.unwrap();assert_eq!(artifacts["artifacts"][0]["name"],"kept.txt"); + app.shutdown().await.unwrap();drop(app);store.shutdown().await.unwrap();drop(store);drop(_dir); + }).await.unwrap(); +} diff --git a/plugins/agent/native/src/owner/tests/checkpoints/workspace.rs b/plugins/agent/native/src/owner/tests/checkpoints/workspace.rs new file mode 100644 index 00000000000..9e7a40e0068 --- /dev/null +++ b/plugins/agent/native/src/owner/tests/checkpoints/workspace.rs @@ -0,0 +1,502 @@ +/* + * 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. + */ + +use super::*; + +fn workspace(mut input: Value, consistency: &str) -> Value { + input["workspaceConsistency"] = json!(consistency); + input +} + +async fn mutate(f: &Fixture, method: &str, input: Value, id: Uuid) -> (RecoveryRef, Value) { + let client = f.app.client(); + let prepared = client + .prepare_mutation( + f.binding(2).await, + method.into(), + input, + scope(&f.issuer, true), + id, + Attachments::new().into(), + ) + .await + .unwrap(); + let reference = prepared.reference().clone(); + let receipt = client + .mutate_owned(prepared) + .await + .unwrap() + .result() + .await + .receipt; + let Receipt::Recorded { + outcome: OperationOutcome::Returned { value }, + .. + } = receipt + else { + panic!("{receipt:?}") + }; + (reference, value) +} + +#[tokio::test] +async fn workspace_checkpoint_restores_same_session_and_runs_next_turn_then_cas() { + tokio::time::timeout(Duration::from_secs(120), async { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let f = Fixture::new(&format!("http://{}/v1", listener.local_addr().unwrap()), None).await; + let session = f.create("Restore workspace").await; + std::fs::write(f.workspace.join("answer.txt"), "checkpoint file").unwrap(); + turn(&f, &listener, &session, "checkpoint history").await; + let original = workspace(input(&f,&session,Value::Null).await, "unverified"); + let (publication_ref,value) = mutate(&f,"publishCheckpoint",original.clone(),Uuid::new_v4()).await; + assert_eq!(value["workspace"]["warning"],"external_writers_not_fenced"); + assert_eq!(value["workspace"]["consistency"],"unverified"); + let head = value["state"]["head"].clone(); + std::fs::write(f.workspace.join("answer.txt"), "source modified later").unwrap(); + + let restore_input = json!({"checkpoint":{"sessionId":session,"revision":head["revision"]}, + "parent":f.workspace,"permissions":{"access":"workspace_write","requestPermissions":true}, + "backend":model_backend(&f.selection().await),"acceptExternalWritersRisk":true}); + let observed = f.query("observe", json!({"sessionId":session})).await.unwrap(); + let marked = f.mutation(2,"markRead",json!({"sessionId":session,"messageId":observed["session"]["tailMessageId"]}),Uuid::new_v4()).await.unwrap(); + assert_eq!(marked["kind"],"observed"); + let current = f.query("observe", json!({"sessionId":session})).await.unwrap(); + let changed = f.mutation(2,"metadata",json!({"sessionId":session,"expectedRevision":current["session"]["revision"],"title":"Renamed after checkpoint","labels":[],"pinned":true}),Uuid::new_v4()).await.unwrap(); + assert_eq!(changed["kind"],"committed"); + let operation = Uuid::new_v4(); + let (restore_ref,result) = mutate(&f,"restoreSessionCheckpoint",restore_input.clone(),operation).await; + assert_eq!(result["sessionId"],session); + assert_eq!(result["head"],head); + assert_eq!(result["workspace"]["consistency"],"unverified"); + let target = std::path::PathBuf::from(result["path"].as_str().unwrap()); + assert_eq!(std::fs::read_to_string(target.join("answer.txt")).unwrap(),"checkpoint file"); + assert_eq!(std::fs::read_to_string(f.workspace.join("answer.txt")).unwrap(),"source modified later"); + let active = f.owner.session(&session).await.unwrap(); + assert_eq!(active.configuration.workspace.path,result["path"].as_str().unwrap()); + assert_eq!(f.mutation(2,"restoreSessionCheckpoint",restore_input.clone(),operation).await.unwrap(),result); + let repeated = f.mutation(2,"restoreSessionCheckpoint",restore_input.clone(),Uuid::new_v4()).await.unwrap(); + assert_ne!(repeated["path"], result["path"]); + assert_eq!(repeated["head"], head); + turn(&f,&listener,&session,"continued after workspace restore").await; + let history=f.query("history",json!({"sessionId":session,"position":{"kind":"tail"}})).await.unwrap().to_string(); + assert!(history.contains("checkpoint history") && history.contains("continued after workspace restore")); + let next = f.mutation(2,"publishCheckpoint",workspace(input(&f,&session,head["revision"].clone()).await, "caller_confirmed_quiescent"),Uuid::new_v4()).await.unwrap(); + assert_eq!(next["state"]["kind"],"published", "{next}"); + assert_ne!(next["state"]["head"]["revision"], head["revision"]); + assert_eq!(f.query("historyCheckpointHead",json!({"sessionId":session})).await.unwrap(),next["state"]["head"]); + assert!(f.mutation(2,"restoreSessionCheckpoint",restore_input.clone(),Uuid::new_v4()).await.is_err()); + assert_eq!(f.query("checkpointReceipt",json!({"reference":publication_ref,"input":original})).await.unwrap()["publication"],value); + let parent=target.parent().unwrap(); + std::fs::rename(parent,parent.with_file_name("moved-restored-parent")).unwrap(); + assert_eq!(f.query("sessionCheckpointRestoreReceipt",json!({"reference":restore_ref,"input":restore_input})).await.unwrap()["restore"],result); + close(&f).await; + }).await.unwrap(); +} + +#[tokio::test] +async fn workspace_checkpoint_requires_explicit_consistency_and_rejects_advanced_history_restore() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let f = Fixture::new( + &format!("http://{}/v1", listener.local_addr().unwrap()), + None, + ) + .await; + let session = f.create("No history rollback").await; + let history = input(&f, &session, Value::Null).await; + assert!( + f.mutation( + 2, + "publishCheckpoint", + json!({"history":history}), + Uuid::new_v4() + ) + .await + .is_err() + ); + let value = f + .mutation( + 2, + "publishCheckpoint", + workspace(history, "caller_confirmed_quiescent"), + Uuid::new_v4(), + ) + .await + .unwrap(); + turn(&f, &listener, &session, "newer history").await; + let request = json!({"checkpoint":{"sessionId":session,"revision":value["state"]["head"]["revision"]},"parent":f.workspace,"permissions":{"access":"read_only","requestPermissions":true},"backend":model_backend(&f.selection().await),"acceptExternalWritersRisk":true}); + assert!( + f.mutation(2, "restoreSessionCheckpoint", request, Uuid::new_v4()) + .await + .is_err() + ); + assert_eq!( + f.owner + .session(&session) + .await + .unwrap() + .configuration + .workspace + .path, + f.workspace.to_str().unwrap() + ); + close(&f).await; +} + +#[tokio::test] +async fn workspace_restore_rejects_an_inflight_goal_producer() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let f = Fixture::new( + &format!("http://{}/v1", listener.local_addr().unwrap()), + None, + ) + .await; + let session = f.create("Goal producer during restore").await; + let value = f + .mutation( + 2, + "publishCheckpoint", + workspace(input(&f, &session, Value::Null).await, "unverified"), + Uuid::new_v4(), + ) + .await + .unwrap(); + let request = json!({"checkpoint":{"sessionId":session,"revision":value["state"]["head"]["revision"]},"parent":f.workspace,"permissions":{"access":"read_only","requestPermissions":true},"backend":model_backend(&f.selection().await),"acceptExternalWritersRisk":true}); + // A Goal producer can be admitted before restore takes the native runs gate, + // yet not have written its first durable record. Its own gate must exclude restore. + let producer = f.owner.goals.admissions.ordered(&session).await; + let result = tokio::time::timeout( + Duration::from_secs(10), + f.mutation( + 2, + "restoreSessionCheckpoint", + request.clone(), + Uuid::new_v4(), + ), + ) + .await + .unwrap(); + assert!( + result.is_err(), + "restore passed an unfinished Goal producer: {result:?}" + ); + assert_eq!( + f.owner + .session(&session) + .await + .unwrap() + .configuration + .workspace + .path, + f.workspace.to_str().unwrap() + ); + drop(producer); + f.mutation(2, "restoreSessionCheckpoint", request, Uuid::new_v4()) + .await + .unwrap(); + close(&f).await; +} + +#[tokio::test] +async fn checkpoint_admission_excludes_shared_workspaces_without_stalling_other_sessions() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let f = Fixture::new( + &format!("http://{}/v1", listener.local_addr().unwrap()), + None, + ) + .await; + let a = f.create("Checkpoint A").await; + let shared = f.create("Shared workspace").await; + let roots = f + .query("directories", json!({"kind":"roots"})) + .await + .unwrap(); + let b = f.mutation(2,"create",json!({"title":"Unrelated B","labels":[],"workspace":{"kind":"directory","path":roots["roots"][1]["path"]}}),Uuid::new_v4()).await.unwrap()["session"]["id"].as_str().unwrap().to_owned(); + let shared_producer = f.owner.runs.ordered(&shared).await; + assert!(f.owner.checkpoint_admission(&a, None).await.is_err()); + drop(shared_producer); + let producer_b = f.owner.goals.admissions.ordered(&b).await; + let (admission, _) = f.owner.checkpoint_admission(&a, None).await.unwrap(); + // Read-only session/history observation remains available while mutations are excluded. + assert!(f.query("observe", json!({"sessionId":a})).await.is_ok()); + assert!( + f.query("observe", json!({"sessionId":shared})) + .await + .is_ok() + ); + assert!( + f.query( + "history", + json!({"sessionId":shared,"position":{"kind":"tail"}}), + ) + .await + .is_ok() + ); + assert!(f.send(&shared).await.is_err()); + assert_eq!( + f.mutation( + 2, + "metadata", + json!({"sessionId":a,"expectedRevision":1,"title":"Busy","labels":[]}), + Uuid::new_v4(), + ) + .await + .unwrap()["reason"], + "busy" + ); + assert!(f.owner.checkpoint_admission(&shared, None).await.is_err()); + drop(producer_b); + let (other_checkpoint, _) = f.owner.checkpoint_admission(&b, None).await.unwrap(); + drop(other_checkpoint); + tokio::time::timeout( + Duration::from_secs(30), + turn(&f, &listener, &b, "B progresses during A checkpoint"), + ) + .await + .unwrap(); + let current = f.query("observe", json!({"sessionId":b})).await.unwrap(); + let changed = f.mutation(2,"metadata",json!({"sessionId":b,"expectedRevision":current["session"]["revision"],"title":"B still writable","labels":[]}),Uuid::new_v4()).await.unwrap(); + assert_eq!(changed["kind"], "committed"); + drop(admission); + assert!(f.owner.mutable_session(&shared).await.is_ok()); + // An admitted run in a distinct directory must not block A's capture. + f.send(&b).await.unwrap(); + let (stream, _) = tokio::time::timeout(Duration::from_secs(15), listener.accept()) + .await + .unwrap() + .unwrap(); + let result = f + .mutation( + 2, + "publishCheckpoint", + workspace(input(&f, &a, Value::Null).await, "unverified"), + Uuid::new_v4(), + ) + .await + .unwrap(); + assert_eq!(result["state"]["kind"], "published"); + drop(stream); + close(&f).await; +} + +#[tokio::test] +async fn active_goal_defers_during_shared_checkpoint_and_restore_enforces_tilde_and_target_parent_fencing() + { + tokio::time::timeout(Duration::from_secs(90), async { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let f = Fixture::configured_with_temp_home( + &format!("http://{}/v1", listener.local_addr().unwrap()), + |settings| { + settings.goals = true; + settings.tool_discovery = true; + }, + ) + .await; + let a = f.create("Checkpoint A").await; + let shared = f.create("Shared goal session").await; + let roots = f + .query("directories", json!({"kind":"roots"})) + .await + .unwrap(); + let b = f + .mutation( + 2, + "create", + json!({"title":"Target parent B","labels":[],"workspace":{"kind":"directory","path":roots["roots"][1]["path"]}}), + Uuid::new_v4(), + ) + .await + .unwrap()["session"]["id"] + .as_str() + .unwrap() + .to_owned(); + + // Arm a Goal on `shared` (start=false), hold checkpoint admission on `a`, then start `shared`'s Goal + // — wait: starting while `a` holds admission must reject `changeGoal` on `shared` as busy, + // and if `shared`'s Goal is already active while `shared`'s turn finishes into `a`'s checkpoint, + // the Goal tick must defer cleanly (never marking `authorityBlocked`) and resume once `a` releases! + f.send(&shared).await.unwrap(); + let (mut shared_turn, _) = listener.accept().await.unwrap(); + request(&mut shared_turn).await; + let shared_cfg = f.owner.session(&shared).await.unwrap().configuration; + let goal_approval = json!({ + "target":{ + "sessionId":shared, + "workspace":shared_cfg.workspace, + "permissions":shared_cfg.permissions, + "boundaryRevision":shared_cfg.boundary_revision, + "binding":shared_cfg.binding + }, + "model":f.selection().await, + "thinkingLevel":null + }); + let created_goal = f + .mutation( + 2, + "changeGoal", + json!({"kind":"create","sessionId":shared,"objective":"Finish after checkpoint","start":true,"tokenBudget":null,"approval":goal_approval}), + Uuid::new_v4(), + ) + .await + .unwrap(); + assert_eq!(created_goal["kind"], "committed"); + response_with_text(&mut shared_turn, "initial turn done").await; + // Accept the Goal's first continuation turn, pause before completing, finish it, then hold `a`'s checkpoint: + let (mut goal_turn_1, _) = tokio::time::timeout(Duration::from_secs(15), listener.accept()) + .await + .unwrap() + .unwrap(); + request(&mut goal_turn_1).await; + response_with_text(&mut goal_turn_1, "working on goal step 1").await; + // Wait for `shared` to settle idle or next goal tick; grab admission on `a` as soon as `shared` is between turns, + // OR test holding `admission` on `a` when `Goals::execution_changed()` fires! + let (mut goal_turn_2, _) = tokio::time::timeout(Duration::from_secs(15), listener.accept()) + .await + .unwrap() + .unwrap(); + request(&mut goal_turn_2).await; + // Manually insert `a` into `runs.checkpoints` to simulate `a` holding workspace checkpoint right as `goal_turn_2` finishes! + let held = f + .owner + .runs + .checkpoint(&a, vec![f.workspace.clone()]) + .unwrap(); + response_with_text(&mut goal_turn_2, "working on goal step 2").await; + f.drained().await; + f.owner.goals.execution_changed(); + tokio::time::sleep(Duration::from_millis(120)).await; + let during = f.query("goal", json!({"sessionId":shared})).await.unwrap()["current"].clone(); + assert_eq!( + during["status"], "active", + "Goal must defer instead of failing: {during}" + ); + assert_eq!(during["note"], ""); + assert!( + !f.owner + .goals + .repository + .read(&shared) + .await + .unwrap() + .unwrap() + .goal + .authority_blocked + ); + drop(held); + f.owner.goals.execution_changed(); + let (mut goal_turn_3, _) = tokio::time::timeout(Duration::from_secs(15), listener.accept()) + .await + .unwrap() + .unwrap(); + let sent = request(&mut goal_turn_3).await; + let has_exec = sent["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["function"]["name"] == "exec"); + let (tool_name, tool_input) = if has_exec { + ( + "exec", + json!({"code":"text(await tools.GoalStatus({\"status\":\"achieved\",\"note\":\"done after checkpoint\"}));"}), + ) + } else { + ( + "GoalStatus", + json!({"status":"achieved","note":"done after checkpoint"}), + ) + }; + service_tools::named_tool_response(&mut goal_turn_3, tool_name, tool_input).await; + let (mut goal_finish, _) = tokio::time::timeout(Duration::from_secs(15), listener.accept()) + .await + .unwrap() + .unwrap(); + request(&mut goal_finish).await; + response_with_text(&mut goal_finish, "goal completed").await; + f.drained().await; + + // Publish checkpoint on `a`, then verify non-Remote scope rejection, busy target `parent` rejection, and `~/` parent expansion. + std::fs::write(f.workspace.join("tilde.txt"), "restored via tilde").unwrap(); + let published = f + .mutation( + 2, + "publishCheckpoint", + workspace(input(&f, &a, Value::Null).await, "unverified"), + Uuid::new_v4(), + ) + .await + .unwrap(); + let revision = published["state"]["head"]["revision"].clone(); + let bg_scope = f + .issuer + .issue_with( + Identity::Background { + grant: maka_capabilities::authorization::Id(Uuid::new_v4()), + }, + true, + CancellationToken::new(), + ) + .unwrap(); + assert!( + f.owner + .preview_history_checkpoint( + contract::SessionRead { + session_id: a.clone() + }, + &bg_scope, + ) + .await + .is_err() + ); + + let restore_tilde = json!({ + "checkpoint":{"sessionId":a,"revision":revision}, + "parent":"~/other-workspace", + "permissions":{"access":"workspace_write","requestPermissions":true}, + "backend":model_backend(&f.selection().await), + "acceptExternalWritersRisk":true + }); + // Active run in `~/other-workspace` (`b`) must block restoring `a` into `~/other-workspace`. + f.send(&b).await.unwrap(); + let (mut b_socket, _) = tokio::time::timeout(Duration::from_secs(15), listener.accept()) + .await + .unwrap() + .unwrap(); + assert!( + f.mutation(2, "restoreSessionCheckpoint", restore_tilde.clone(), Uuid::new_v4()) + .await + .is_err() + ); + request(&mut b_socket).await; + response_with_text(&mut b_socket, "b finished").await; + f.drained().await; + + let restored = f + .mutation(2, "restoreSessionCheckpoint", restore_tilde, Uuid::new_v4()) + .await + .unwrap(); + let restored_path = std::path::PathBuf::from(restored["path"].as_str().unwrap()); + assert!(restored_path.starts_with(roots["roots"][1]["path"].as_str().unwrap())); + assert_eq!( + std::fs::read_to_string(restored_path.join("tilde.txt")).unwrap(), + "restored via tilde" + ); + close(&f).await; + }) + .await + .unwrap(); +} diff --git a/plugins/agent/native/src/owner/tests/session_parent_authority.rs b/plugins/agent/native/src/owner/tests/session_parent_authority.rs index 4c553e4622a..c365539d56e 100644 --- a/plugins/agent/native/src/owner/tests/session_parent_authority.rs +++ b/plugins/agent/native/src/owner/tests/session_parent_authority.rs @@ -226,23 +226,23 @@ async fn settled_self_response(f: &Fixture, record: &InteractionRecord) -> Value } _ => None, }); - if let Some(operation) = operation { - if let Some(outcome) = facts.iter().find_map(|fact| match fact { + if let Some(operation) = operation + && let Some(outcome) = facts.iter().find_map(|fact| match fact { Fact::ToolSettled { operation_id, outcome, } if operation_id == operation => Some(outcome), _ => None, - }) { - let ToolOutcome::Succeeded { - model_projection: DurableToolProjection::Json { value }, - .. - } = outcome - else { - panic!("self response must return a typed decision: {outcome:?}"); - }; - return value.clone(); - } + }) + { + let ToolOutcome::Succeeded { + model_projection: DurableToolProjection::Json { value }, + .. + } = outcome + else { + panic!("self response must return a typed decision: {outcome:?}"); + }; + return value.clone(); } commits.changed().await.unwrap(); } @@ -366,7 +366,7 @@ async fn model_parent_can_answer_only_direct_children_and_cannot_edit_permission let mut sibling_socket = start(&f, &listener, &sibling).await; let mut unrelated_socket = start(&f, &listener, &unrelated).await; let permissions = grant::Permissions { - filesystem: vec![Rule::exact(&f.workspace.join("result"), Access::Write)], + filesystem: vec![Rule::exact(f.workspace.join("result"), Access::Write)], network: Network::Denied, }; let answer = InteractionAnswer::Permissions { diff --git a/plugins/agent/native/src/owner/title.rs b/plugins/agent/native/src/owner/title.rs index 0f81cd720b8..def4da3ba03 100644 --- a/plugins/agent/native/src/owner/title.rs +++ b/plugins/agent/native/src/owner/title.rs @@ -386,6 +386,11 @@ impl Owner { // Main execution also moves the public revision. Retry metadata only; // each attempt gives queued manual changes and shutdown a turn at the gate. loop { + let _admission = tokio::select! { + biased; + () = cancellation.cancelled() => return Ok(()), + admission = self.runs.ordered(&invocation.session_id) => admission, + }; let runs = tokio::select! { biased; () = cancellation.cancelled() => return Ok(()), diff --git a/plugins/agent/native/src/service.rs b/plugins/agent/native/src/service.rs index 996e6c9c948..47e80910024 100644 --- a/plugins/agent/native/src/service.rs +++ b/plugins/agent/native/src/service.rs @@ -370,26 +370,65 @@ impl Handler for Agent { .preview_history_checkpoint(decode(input)?, context.scope()) .await?, ), - "publishHistoryCheckpoint" => { + "historyCheckpointHead" => encode( + owner + .history_checkpoint_head(decode(input)?, context.scope()) + .await?, + ), + "readHistoryCheckpoint" => { + owner.read_history_checkpoint(decode(input)?, context).await + } + "loadHistoryCheckpoint" => { + let operation = context.operation().ok_or("agent_mutation_required")?; + encode( + owner + .load_history_checkpoint(decode(input)?, operation, context.scope()) + .await?, + ) + } + "restoreSessionCheckpoint" => { let operation = context.operation().ok_or("agent_mutation_required")?; encode( owner - .publish_history_checkpoint(decode(input)?, operation, context.scope()) + .restore_session_checkpoint(decode(input)?, operation, context.scope()) .await?, ) } - "historyCheckpointReceipt" => encode( + "sessionCheckpointRestoreReceipt" => encode( owner - .history_checkpoint_receipt(decode(input)?, context) + .session_checkpoint_restore_receipt(decode(input)?, context) .await?, ), - "historyCheckpointHead" => encode( + "publishCheckpoint" => { + let operation = context.operation().ok_or("agent_mutation_required")?; + encode( + owner + .publish_checkpoint(decode(input)?, operation, context.scope()) + .await?, + ) + } + "checkpointReceipt" => { + encode(owner.checkpoint_receipt(decode(input)?, context).await?) + } + "historyCheckpointLoadReceipt" => encode( owner - .history_checkpoint_head(decode(input)?, context.scope()) + .history_checkpoint_load_receipt(decode(input)?, context) .await?, ), - "readHistoryCheckpoint" => { - owner.read_history_checkpoint(decode(input)?, context).await + "loadedHistoryCheckpointHistory" => checkpoint_output( + owner + .loaded_checkpoint_history(decode(input)?, context.scope()) + .await?, + ), + "loadedHistoryCheckpointArtifacts" => checkpoint_output( + owner + .loaded_checkpoint_artifacts(decode(input)?, context.scope()) + .await?, + ), + "loadedHistoryCheckpointArtifact" => { + owner + .loaded_checkpoint_artifact(decode(input)?, context) + .await } "fork" => { let operation = context.operation().ok_or("agent_mutation_required")?; @@ -571,7 +610,6 @@ impl Handler for Agent { Ok(result) } "history" => { - use maka_agent_ports::session::history::Page; use maka_agent_ports::session::history::Read; let input: History = decode(input)?; owner.require_session(&input.session_id).await?; @@ -593,13 +631,8 @@ impl Handler for Agent { } .validate() .map_err(|_| "agent_cursor_invalid")?; - let result = { - let Page::Ready { - through, - chunks, - before, - after, - } = owner + let result = HistoryPage::from( + owner .scoped_store(context.scope(), &input.session_id) .await? .history_view( @@ -617,46 +650,9 @@ impl Handler for Agent { }, ) .await - .map_err(|_| "agent_history_unavailable")?; - HistoryPage::Ready { - through, - before, - after, - chunks: chunks - .into_iter() - .map(|chunk| HistoryChunk { - model_part: chunk.model_part, - has_references: chunk.has_references, - final_output: chunk.final_output, - model: chunk.model, - origin: chunk.origin, - compaction: chunk.compaction, - attachments: chunk - .attachments - .into_iter() - .map(|attachment| HistoryAttachment { - name: attachment.name, - mime_type: attachment.mime_type, - bytes: attachment.bytes, - kind: attachment.kind, - }) - .collect(), - timing: chunk.timing, - field: chunk.field, - message_id: chunk.message_id, - turn_id: chunk.turn_id, - timestamp: chunk.timestamp, - role: chunk.role, - order: chunk.order, - position: chunk.position, - offset: chunk.offset, - total_bytes: chunk.total_bytes, - text: chunk.text, - tool: chunk.tool, - }) - .collect(), - } - }; + .map_err(|_| "agent_history_unavailable")?, + ); + owner.require_session(&input.session_id).await?; if context.scope().cancellation.is_cancelled() { return Err("agent_observation_cancelled".into()); @@ -1364,6 +1360,12 @@ async fn catalog_page(owner: &Owner, input: &List) -> Result(input: Value) -> Result { serde_json::from_value(input).map_err(|_| "agent_input_invalid".into()) } +fn checkpoint_output(value: T) -> Result { + let value = encode(value)?; + maka_plugin_core::json::exact_numbers(&value) + .map_err(|_| "agent_history_checkpoint_output_unavailable")?; + Ok(value) +} fn encode(value: T) -> Result { serde_json::to_value(value).map_err(|_| "agent_output_unavailable".into()) } diff --git a/products/maka/cli/src/acp/tests/public_client.rs b/products/maka/cli/src/acp/tests/public_client.rs index 568b7d6e6f8..8fc72431f7e 100644 --- a/products/maka/cli/src/acp/tests/public_client.rs +++ b/products/maka/cli/src/acp/tests/public_client.rs @@ -130,8 +130,8 @@ async fn published_product_endpoint_exposes_history_checkpoint_and_original_rece tokio::time::timeout(Duration::from_secs(60),async { let mut fixture=Fixture::open().await; let preview=support::query(&fixture.native,"agent","previewHistoryCheckpoint",json!({"sessionId":fixture.session})).await; - let input=json!({"sessionId":fixture.session,"expectedRevision":null,"confirmedSubtreeDigest":preview["subtreeDigest"]}); - let mut call=fixture.native.call("agent","publishHistoryCheckpoint".into(),input.clone(),Default::default()).await.unwrap(); + let input=json!({"sessionId":fixture.session,"expectedRevision":null,"confirmedSubtreeDigest":preview["subtreeDigest"],"workspaceConsistency":null}); + let mut call=fixture.native.call("agent","publishCheckpoint".into(),input.clone(),Default::default()).await.unwrap(); call.start().await.unwrap(); let Reply::Prepared {reference}=call.receive().await.unwrap() else {panic!("original operation reference")}; let Reply::Mutation {reply,cleanup}=call.confirm().await.unwrap() else {panic!("publication receipt")}; @@ -139,7 +139,7 @@ async fn published_product_endpoint_exposes_history_checkpoint_and_original_rece let Receipt::Recorded {outcome:OperationOutcome::Returned {value},..}=reply.receipt else {panic!("publication failed")}; assert_eq!(value["state"]["kind"],"published"); call.close().await.unwrap(); - let receipt=support::query(&fixture.native,"agent","historyCheckpointReceipt",json!({"reference":reference,"input":input})).await; + let receipt=support::query(&fixture.native,"agent","checkpointReceipt",json!({"reference":reference,"input":input})).await; assert_eq!(receipt["publication"],value); let head=support::query(&fixture.native,"agent","historyCheckpointHead",json!({"sessionId":fixture.session})).await; assert_eq!(head,value["state"]["head"]); @@ -150,7 +150,7 @@ async fn published_product_endpoint_exposes_history_checkpoint_and_original_rece loop { match stream.pull().await.unwrap() { Reply::Item {value}=>match value["kind"].as_str().unwrap() { - "native_history_v1"=>assert_eq!(value["head"],head), + "checkpoint"=>assert_eq!(value["head"],head), "chunk"=>{assert_eq!(value["offset"],bytes.len() as u64); bytes.extend(STANDARD.decode(value["data"].as_str().unwrap()).unwrap());}, "end"=>{assert_eq!(value["bytes"],bytes.len() as u64); ended=true;}, _=>panic!("{value}"), @@ -162,6 +162,19 @@ async fn published_product_endpoint_exposes_history_checkpoint_and_original_rece assert!(ended); let mut stage=maka_event_log::bundle::StagedBundle::read(bytes.as_slice()).await.unwrap(); stage.validate_history().await.unwrap(); + stage.close().await.unwrap(); + let load_input=json!({"sessionId":fixture.session,"revision":head["revision"]}); + let mut loading=fixture.native.call("agent","loadHistoryCheckpoint".into(),load_input.clone(),Default::default()).await.unwrap(); + loading.start().await.unwrap(); + let Reply::Prepared {reference:load_ref}=loading.receive().await.unwrap() else {panic!("load not prepared")}; + let Reply::Mutation {reply,cleanup}=loading.confirm().await.unwrap() else {panic!("load not returned")}; + cleanup.unwrap(); + let Receipt::Recorded {outcome:OperationOutcome::Returned {value:loaded},..}=reply.receipt else {panic!("load failed")}; + assert_eq!(loaded["state"]["kind"],"ready");loading.close().await.unwrap(); + let observed=support::query(&fixture.native,"agent","historyCheckpointLoadReceipt",json!({"reference":load_ref,"input":load_input})).await; + assert_eq!(observed["load"],loaded); + let history=support::query(&fixture.native,"agent","loadedHistoryCheckpointHistory",json!({"inspection":loaded["inspection"],"sessionId":fixture.session,"position":{"kind":"start"}})).await; + assert_eq!(history["kind"],"ready"); assert!(fixture.model_requests.try_recv().is_err(),"checkpoint management never runs the model"); stream.close().await.unwrap(); fixture.native.close().await.unwrap(); fixture.client.quit().await.unwrap();