codex-chatgpt-control 0.5.1-alpha.2 → 0.5.1-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +43 -0
- package/contracts/v1/fixtures/backend-capabilities.json +40 -1
- package/contracts/v1/fixtures/backend-compatibility.json +21 -0
- package/contracts/v1/fixtures/backend-version.json +6 -1
- package/contracts/v1/fixtures/download-blocked-by-browser.json +22 -0
- package/contracts/v1/fixtures/download-receipt-failed.json +22 -0
- package/contracts/v1/fixtures/download-receipt-timeout.json +22 -0
- package/contracts/v1/fixtures/journal-rpc-indeterminate.json +32 -0
- package/contracts/v1/fixtures/journal-rpc-unsupported-platform.json +32 -0
- package/contracts/v1/fixtures/journal-runtime-unavailable.json +32 -0
- package/contracts/v1/fixtures/operation-action-prepared-event.json +42 -0
- package/contracts/v1/fixtures/operation-action.json +13 -0
- package/contracts/v1/fixtures/operation-artifact-receipt.json +14 -0
- package/contracts/v1/fixtures/operation-artifact-transfer-intent-event.json +24 -0
- package/contracts/v1/fixtures/operation-artifact-transfer-receipt-event.json +26 -0
- package/contracts/v1/fixtures/operation-artifact-transfer-state.json +178 -0
- package/contracts/v1/fixtures/operation-blocker.json +10 -0
- package/contracts/v1/fixtures/operation-collect-request.json +17 -0
- package/contracts/v1/fixtures/operation-collect-result.json +42 -0
- package/contracts/v1/fixtures/operation-control-receipt.json +13 -0
- package/contracts/v1/fixtures/operation-control-request.json +18 -0
- package/contracts/v1/fixtures/operation-control-result.json +31 -0
- package/contracts/v1/fixtures/operation-event.json +18 -0
- package/contracts/v1/fixtures/operation-handle.json +10 -0
- package/contracts/v1/fixtures/operation-inspect-request.json +13 -0
- package/contracts/v1/fixtures/operation-inspect-result.json +205 -0
- package/contracts/v1/fixtures/operation-ownership-baseline-event.json +34 -0
- package/contracts/v1/fixtures/operation-receipt.json +32 -0
- package/contracts/v1/fixtures/operation-recovery-decision.json +7 -0
- package/contracts/v1/fixtures/operation-recovery-observation.json +12 -0
- package/contracts/v1/fixtures/operation-request.json +33 -0
- package/contracts/v1/fixtures/operation-state.json +144 -0
- package/contracts/v1/fixtures/operation-submission-witness-event.json +20 -0
- package/contracts/v1/fixtures/operation-submission-witness.json +11 -0
- package/contracts/v1/fixtures/operation-submit-result.json +16 -0
- package/contracts/v1/fixtures/operation-target-established-event.json +21 -0
- package/contracts/v1/manifest.json +185 -1
- package/contracts/v1/parity-suite.json +106 -8
- package/contracts/v1/schemas/backend-compatibility.schema.json +65 -0
- package/contracts/v1/schemas/backend-request.schema.json +8 -2
- package/contracts/v1/schemas/capabilities.schema.json +81 -1
- package/contracts/v1/schemas/manifest.schema.json +57 -0
- package/contracts/v1/schemas/operation-action.schema.json +155 -0
- package/contracts/v1/schemas/operation-artifact-receipt.schema.json +89 -0
- package/contracts/v1/schemas/operation-blocker.schema.json +70 -0
- package/contracts/v1/schemas/operation-collect-request.schema.json +46 -0
- package/contracts/v1/schemas/operation-collect-result.schema.json +74 -0
- package/contracts/v1/schemas/operation-control-receipt.schema.json +108 -0
- package/contracts/v1/schemas/operation-control-request.schema.json +111 -0
- package/contracts/v1/schemas/operation-control-result.schema.json +112 -0
- package/contracts/v1/schemas/operation-event.schema.json +756 -0
- package/contracts/v1/schemas/operation-handle.schema.json +55 -0
- package/contracts/v1/schemas/operation-inspect-request.schema.json +42 -0
- package/contracts/v1/schemas/operation-inspect-result.schema.json +1662 -0
- package/contracts/v1/schemas/operation-receipt.schema.json +153 -0
- package/contracts/v1/schemas/operation-recovery.schema.json +314 -0
- package/contracts/v1/schemas/operation-request.schema.json +158 -0
- package/contracts/v1/schemas/operation-state.schema.json +691 -0
- package/contracts/v1/schemas/operation-submission-witness.schema.json +48 -0
- package/contracts/v1/schemas/operation-submit-result.schema.json +94 -0
- package/contracts/v1/surface-drift-policy.json +50 -0
- package/contracts/v1/vectors/operation-request-digest-v1.json +43 -0
- package/dist/codex-chatgpt-control-backend.mjs +45882 -9361
- package/dist/codex-chatgpt-control-journal.mjs +5547 -0
- package/dist/codex-chatgpt-control-live-smoke.bundle.mjs +51677 -0
- package/dist/codex-chatgpt-control-release-canary.bundle.mjs +52104 -0
- package/dist/codex-chatgpt-control.bundle.mjs +48392 -10260
- package/dist/src/backend/client.d.ts +103 -2
- package/dist/src/backend/client.js +1436 -97
- package/dist/src/backend/compatibility.d.ts +12 -0
- package/dist/src/backend/compatibility.js +208 -0
- package/dist/src/backend/protocol.d.ts +84 -1
- package/dist/src/backend/protocol.js +31 -5
- package/dist/src/backend/runtime-identity.d.ts +10 -0
- package/dist/src/backend/runtime-identity.js +141 -0
- package/dist/src/backend/session.d.ts +10 -2
- package/dist/src/backend/session.js +478 -7
- package/dist/src/backend/stdio-server.d.ts +4 -1
- package/dist/src/backend/stdio-server.js +240 -28
- package/dist/src/browser/active-composer-file-input.d.ts +9 -0
- package/dist/src/browser/active-composer-file-input.js +33 -0
- package/dist/src/browser/attach.d.ts +4 -1
- package/dist/src/browser/attach.js +136 -28
- package/dist/src/browser/chatgpt-url.d.ts +2 -0
- package/dist/src/browser/chatgpt-url.js +9 -0
- package/dist/src/browser/downloads.d.ts +17 -1
- package/dist/src/browser/downloads.js +150 -28
- package/dist/src/browser/page-state.js +6 -0
- package/dist/src/client.d.ts +43 -0
- package/dist/src/client.js +1279 -59
- package/dist/src/commands/artifacts.js +6 -4
- package/dist/src/commands/chat-popover.d.ts +37 -0
- package/dist/src/commands/chat-popover.js +537 -0
- package/dist/src/commands/configuration.d.ts +5 -0
- package/dist/src/commands/configuration.js +134 -21
- package/dist/src/commands/context.js +10 -3
- package/dist/src/commands/doctor.d.ts +1 -1
- package/dist/src/commands/doctor.js +27 -4
- package/dist/src/commands/experience.d.ts +5 -0
- package/dist/src/commands/experience.js +104 -21
- package/dist/src/commands/files.js +14 -33
- package/dist/src/commands/modes.js +85 -21
- package/dist/src/commands/power-discovery.d.ts +130 -0
- package/dist/src/commands/power-discovery.js +669 -0
- package/dist/src/commands/project-sources.js +325 -31
- package/dist/src/commands/sequence.js +59 -32
- package/dist/src/commands/session.js +19 -6
- package/dist/src/commands/work.js +31 -0
- package/dist/src/dom/composer-text.d.ts +6 -0
- package/dist/src/dom/composer-text.js +85 -0
- package/dist/src/errors.d.ts +9 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +9 -0
- package/dist/src/operations/artifact-output.d.ts +69 -0
- package/dist/src/operations/artifact-output.js +1748 -0
- package/dist/src/operations/artifact-stream.d.ts +19 -0
- package/dist/src/operations/artifact-stream.js +35 -0
- package/dist/src/operations/artifact-transfer.d.ts +149 -0
- package/dist/src/operations/artifact-transfer.js +1312 -0
- package/dist/src/operations/browser-adapter.d.ts +167 -0
- package/dist/src/operations/browser-adapter.js +2103 -0
- package/dist/src/operations/browser-observation.d.ts +110 -0
- package/dist/src/operations/browser-observation.js +1256 -0
- package/dist/src/operations/browser-target.d.ts +105 -0
- package/dist/src/operations/browser-target.js +531 -0
- package/dist/src/operations/canonical.d.ts +4 -0
- package/dist/src/operations/canonical.js +412 -0
- package/dist/src/operations/chatgpt-runtime.d.ts +72 -0
- package/dist/src/operations/chatgpt-runtime.js +1232 -0
- package/dist/src/operations/client.d.ts +138 -0
- package/dist/src/operations/client.js +1145 -0
- package/dist/src/operations/collector.d.ts +195 -0
- package/dist/src/operations/collector.js +1039 -0
- package/dist/src/operations/configuration-routing.d.ts +4 -0
- package/dist/src/operations/configuration-routing.js +14 -0
- package/dist/src/operations/control.d.ts +372 -0
- package/dist/src/operations/control.js +1498 -0
- package/dist/src/operations/file-identity.d.ts +33 -0
- package/dist/src/operations/file-identity.js +153 -0
- package/dist/src/operations/handle.d.ts +19 -0
- package/dist/src/operations/handle.js +727 -0
- package/dist/src/operations/index.d.ts +25 -0
- package/dist/src/operations/index.js +25 -0
- package/dist/src/operations/journal-authority.d.ts +12 -0
- package/dist/src/operations/journal-authority.js +1 -0
- package/dist/src/operations/journal-rpc-client.d.ts +12 -0
- package/dist/src/operations/journal-rpc-client.js +225 -0
- package/dist/src/operations/journal-rpc-protocol.d.ts +32 -0
- package/dist/src/operations/journal-rpc-protocol.js +214 -0
- package/dist/src/operations/journal-rpc-server.d.ts +15 -0
- package/dist/src/operations/journal-rpc-server.js +272 -0
- package/dist/src/operations/journal.d.ts +143 -0
- package/dist/src/operations/journal.js +1957 -0
- package/dist/src/operations/production-attachments.d.ts +118 -0
- package/dist/src/operations/production-attachments.js +1035 -0
- package/dist/src/operations/production-chatgpt-artifacts.d.ts +74 -0
- package/dist/src/operations/production-chatgpt-artifacts.js +1146 -0
- package/dist/src/operations/production-chatgpt-attachments.d.ts +80 -0
- package/dist/src/operations/production-chatgpt-attachments.js +1639 -0
- package/dist/src/operations/production-configuration.d.ts +42 -0
- package/dist/src/operations/production-configuration.js +1544 -0
- package/dist/src/operations/production-primitives.d.ts +53 -0
- package/dist/src/operations/production-primitives.js +856 -0
- package/dist/src/operations/production-work-steer.d.ts +203 -0
- package/dist/src/operations/production-work-steer.js +1218 -0
- package/dist/src/operations/recovery.d.ts +80 -0
- package/dist/src/operations/recovery.js +174 -0
- package/dist/src/operations/runtime-adapter.d.ts +126 -0
- package/dist/src/operations/runtime-adapter.js +802 -0
- package/dist/src/operations/send-once.d.ts +224 -0
- package/dist/src/operations/send-once.js +1081 -0
- package/dist/src/operations/service.d.ts +286 -0
- package/dist/src/operations/service.js +2831 -0
- package/dist/src/operations/staging.d.ts +136 -0
- package/dist/src/operations/staging.js +630 -0
- package/dist/src/operations/state-machine.d.ts +29 -0
- package/dist/src/operations/state-machine.js +2047 -0
- package/dist/src/operations/submission.d.ts +423 -0
- package/dist/src/operations/submission.js +1676 -0
- package/dist/src/operations/transactional-chat-power.d.ts +17 -0
- package/dist/src/operations/transactional-chat-power.js +212 -0
- package/dist/src/operations/turn-ownership.d.ts +202 -0
- package/dist/src/operations/turn-ownership.js +700 -0
- package/dist/src/operations/types.d.ts +454 -0
- package/dist/src/operations/types.js +20 -0
- package/dist/src/operations/wire-requests.d.ts +21 -0
- package/dist/src/operations/wire-requests.js +396 -0
- package/dist/src/operations/wire-results.d.ts +97 -0
- package/dist/src/operations/wire-results.js +818 -0
- package/dist/src/runner/responses.d.ts +3 -1
- package/dist/src/runner/responses.js +19 -1
- package/dist/src/runner/result.js +227 -55
- package/dist/src/runner/types.d.ts +12 -0
- package/dist/src/runtime/command-routing.d.ts +149 -0
- package/dist/src/runtime/command-routing.js +431 -0
- package/dist/src/runtime/coordinated-browser.d.ts +24 -0
- package/dist/src/runtime/coordinated-browser.js +315 -0
- package/dist/src/runtime/coordinated-page.d.ts +41 -0
- package/dist/src/runtime/coordinated-page.js +651 -0
- package/dist/src/runtime/operation-context.d.ts +142 -0
- package/dist/src/runtime/operation-context.js +410 -0
- package/dist/src/runtime/runtime-session.d.ts +95 -0
- package/dist/src/runtime/runtime-session.js +314 -0
- package/dist/src/runtime/tab-coordinator.d.ts +200 -0
- package/dist/src/runtime/tab-coordinator.js +1200 -0
- package/dist/src/runtime/value-boundaries.d.ts +18 -0
- package/dist/src/runtime/value-boundaries.js +49 -0
- package/dist/src/safety/blockers.js +8 -1
- package/dist/src/safety/untrusted-output.js +2 -1
- package/dist/src/scripts/backend-server.js +4 -1
- package/dist/src/scripts/capture-surface-profile.js +7 -2
- package/dist/src/scripts/journal-server.d.ts +2 -0
- package/dist/src/scripts/journal-server.js +37 -0
- package/dist/src/scripts/live-smoke/harness.js +79 -7
- package/dist/src/scripts/live-smoke/scenarios.d.ts +42 -1
- package/dist/src/scripts/live-smoke/scenarios.js +147 -30
- package/dist/src/scripts/live-smoke/transactional.d.ts +17 -0
- package/dist/src/scripts/live-smoke/transactional.js +254 -0
- package/dist/src/scripts/live-smoke/types.d.ts +3 -0
- package/dist/src/scripts/release-canary-module.js +17 -4
- package/dist/src/types.d.ts +38 -1
- package/package.json +8 -3
- package/references/2026-08-16-transactional-operations.md +459 -0
- package/references/2026-09-06-journal-service.md +140 -0
- package/references/agents-runner.md +34 -0
- package/references/backend-protocol.md +176 -0
- package/references/python-parity.md +137 -0
- package/references/responses-adapter.md +19 -0
- package/references/streaming.md +6 -0
|
@@ -0,0 +1,2831 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { canonicalJson } from "./canonical.js";
|
|
3
|
+
import { OperationJournalError } from "./journal.js";
|
|
4
|
+
import { configurationHasMenuValues, configurationPowerValues } from "./configuration-routing.js";
|
|
5
|
+
import { collectOperation } from "./collector.js";
|
|
6
|
+
import { runAtomicSubmission } from "./submission.js";
|
|
7
|
+
import { runOperationControl, CONTROL_COORDINATOR_SCHEMA_VERSION, controlSteerPreparedDigestMaterial } from "./control.js";
|
|
8
|
+
import { runOperationStaging } from "./staging.js";
|
|
9
|
+
import { assertOperationStateShape, assertOwnershipBaselineShape, durableCapturePolicyFromRequest } from "./state-machine.js";
|
|
10
|
+
import { OPERATION_CONTROL_RECEIPT_SCHEMA_VERSION, OPERATION_HANDLE_SCHEMA_VERSION, OPERATION_RECEIPT_SCHEMA_VERSION, OPERATION_OWNERSHIP_BASELINE_SCHEMA_VERSION, OPERATION_ARTIFACT_TRANSFER_INTENT_SCHEMA_VERSION, OPERATION_ARTIFACT_TRANSFER_RECEIPT_SCHEMA_VERSION, OPERATION_SUBMISSION_WITNESS_SCHEMA_VERSION } from "./types.js";
|
|
11
|
+
/**
|
|
12
|
+
* Journal-owning composition for one transactional browser operation.
|
|
13
|
+
*
|
|
14
|
+
* The adapter types in this module are intentionally narrow. The service
|
|
15
|
+
* receives no DOM objects and no browser/page object; the supplied ports only
|
|
16
|
+
* exchange already-normalized observations. The request prompt, local file
|
|
17
|
+
* paths, and raw response text can therefore remain in the caller/adapter
|
|
18
|
+
* closure while the service owns all durable event writes.
|
|
19
|
+
*/
|
|
20
|
+
const DIGEST_PATTERN = /^hmac-sha256:[0-9a-f]{64}$/;
|
|
21
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
22
|
+
const MAX_CAS_RETRIES = 12;
|
|
23
|
+
const UNCERTAIN_SUBMISSION_BLOCKERS = new Set([
|
|
24
|
+
"ambiguous_file_handoff",
|
|
25
|
+
"ambiguous_submit",
|
|
26
|
+
"journal_unavailable",
|
|
27
|
+
"operation_cancelled",
|
|
28
|
+
"operation_timeout"
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Messages that may cross the service boundary for failures raised by the
|
|
32
|
+
* journal or by a native provider/filesystem seam. Error objects are often
|
|
33
|
+
* populated with paths, URLs, account identifiers, or private provider
|
|
34
|
+
* diagnostics; retaining their message here would turn a safe operation
|
|
35
|
+
* result into an accidental disclosure channel. Keep this table closed and
|
|
36
|
+
* static: callers can use `code` for machine routing and never need the
|
|
37
|
+
* implementation detail that caused it.
|
|
38
|
+
*/
|
|
39
|
+
const SAFE_SERVICE_ERROR_MESSAGES = Object.freeze({
|
|
40
|
+
invalid_operation_handle: "Operation handle could not be validated.",
|
|
41
|
+
operation_not_found: "No durable operation exists for the supplied identity.",
|
|
42
|
+
operation_request_mismatch: "Operation request identity does not match durable state.",
|
|
43
|
+
operation_receipt_expired: "The operation receipt has expired.",
|
|
44
|
+
operation_compacted: "The completed operation is immutable.",
|
|
45
|
+
operation_not_compactable: "Only a completed operation can be compacted.",
|
|
46
|
+
journal_purge_ack_required: "Tombstone purge requires explicit acknowledgement.",
|
|
47
|
+
revision_conflict: "The operation changed concurrently; retry from a fresh handle.",
|
|
48
|
+
invalid_expected_revision: "The expected operation revision is invalid.",
|
|
49
|
+
creation_event_required: "The operation journal requires a creation event first.",
|
|
50
|
+
duplicate_operation_created: "The operation journal already contains its creation event.",
|
|
51
|
+
durable_record_conflict: "A durable operation record already exists.",
|
|
52
|
+
event_not_serializable: "The operation event is not serializable.",
|
|
53
|
+
journal_quota_exceeded: "The operation journal quota is exhausted.",
|
|
54
|
+
journal_unavailable: "The operation journal is unavailable.",
|
|
55
|
+
journal_rpc_unavailable: "The operation journal authority is unavailable.",
|
|
56
|
+
journal_rpc_unsupported_platform: "The private-file journal service is unavailable on this operating system.",
|
|
57
|
+
journal_rpc_authentication_failed: "The operation journal authority could not be authenticated.",
|
|
58
|
+
journal_rpc_protocol_error: "The operation journal authority returned an invalid response.",
|
|
59
|
+
journal_rpc_limit_exceeded: "The operation journal request exceeded its safety bound.",
|
|
60
|
+
journal_rpc_outcome_indeterminate: "The operation journal write may have completed; reconcile durable state before another effect.",
|
|
61
|
+
journal_rpc_request_rejected: "The operation journal authority rejected the request.",
|
|
62
|
+
journal_corrupt: "The operation journal failed integrity validation.",
|
|
63
|
+
journal_snapshot_corrupt: "The operation snapshot failed integrity validation.",
|
|
64
|
+
journal_tombstone_corrupt: "The operation tombstone failed integrity validation.",
|
|
65
|
+
journal_terminal_corrupt: "The operation terminal record failed integrity validation.",
|
|
66
|
+
unsafe_journal_entry: "The operation journal contains an unsafe entry.",
|
|
67
|
+
unsafe_journal_key: "The operation journal key is unsafe.",
|
|
68
|
+
unsafe_state_root: "The operation state root is unsafe.",
|
|
69
|
+
unsafe_state_owner: "The operation state root has an unsafe owner.",
|
|
70
|
+
unsafe_state_permissions: "The operation state root has unsafe permissions.",
|
|
71
|
+
state_root_not_absolute: "The operation state root must be absolute.",
|
|
72
|
+
state_path_escape: "The operation state path is unsafe.",
|
|
73
|
+
journal_lock_timeout: "Timed out waiting for the operation journal lock.",
|
|
74
|
+
journal_lock_corrupt: "The operation journal lock is invalid.",
|
|
75
|
+
journal_lock_changed: "The operation journal lock changed unexpectedly.",
|
|
76
|
+
journal_lock_lost: "The operation journal lock was lost.",
|
|
77
|
+
journal_lock_recovery_abandoned: "The operation journal lock recovery requires manual diagnosis.",
|
|
78
|
+
journal_lock_cleanup_failed: "The operation journal lock could not be cleaned up safely.",
|
|
79
|
+
journal_scan_limit: "The operation journal could not be bounded safely.",
|
|
80
|
+
journal_log_too_large: "The operation log reached its hard safety limit.",
|
|
81
|
+
journal_temp_conflict: "A temporary operation state record already exists.",
|
|
82
|
+
operation_binding_mismatch: "The operation journal identity is inconsistent.",
|
|
83
|
+
invalid_operation_event: "The operation event is invalid.",
|
|
84
|
+
invalid_evidence_domain: "The operation evidence domain is invalid.",
|
|
85
|
+
invalid_evidence_material: "The operation evidence is invalid.",
|
|
86
|
+
evidence_material_too_large: "The operation evidence exceeds its safety limit.",
|
|
87
|
+
invalid_journal_option: "The operation journal options are invalid.",
|
|
88
|
+
invalid_journal_clock: "The operation journal clock is invalid.",
|
|
89
|
+
invalid_journal_entropy: "The operation journal entropy is invalid.",
|
|
90
|
+
invalid_journal_key: "The operation journal key is invalid.",
|
|
91
|
+
journal_key_missing: "The operation journal key is missing.",
|
|
92
|
+
journal_key_missing_with_state: "The operation journal key is missing while durable state exists.",
|
|
93
|
+
default: "The operation failed safely."
|
|
94
|
+
});
|
|
95
|
+
export class OperationServiceError extends Error {
|
|
96
|
+
code;
|
|
97
|
+
constructor(code, message) {
|
|
98
|
+
super(message);
|
|
99
|
+
this.code = code;
|
|
100
|
+
this.name = "OperationServiceError";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Compose the additive operations surface over an authenticated journal.
|
|
105
|
+
* Construct one instance per stable state root and share it between callers.
|
|
106
|
+
*/
|
|
107
|
+
export class OperationService {
|
|
108
|
+
journal;
|
|
109
|
+
now;
|
|
110
|
+
maxCasRetries;
|
|
111
|
+
/**
|
|
112
|
+
* Same-process convergence for a collector race. The journal remains the
|
|
113
|
+
* cross-process authority; this map prevents two callers from even asking
|
|
114
|
+
* an arbitrary adapter to begin the same local effect concurrently.
|
|
115
|
+
*/
|
|
116
|
+
artifactTransfersInFlight = new Map();
|
|
117
|
+
constructor(journal, options = {}) {
|
|
118
|
+
this.journal = journal;
|
|
119
|
+
this.now = options.now ?? Date.now;
|
|
120
|
+
this.maxCasRetries = options.maxCasRetries ?? MAX_CAS_RETRIES;
|
|
121
|
+
if (!Number.isSafeInteger(this.maxCasRetries) || this.maxCasRetries < 1 || this.maxCasRetries > 100) {
|
|
122
|
+
throw new OperationServiceError("invalid_cas_retries", "maxCasRetries must be a positive bounded integer.");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Submit once and return a fresh locator. A successful return means only
|
|
127
|
+
* that the submission result has been reconciled and durably bridged; it
|
|
128
|
+
* does not wait for assistant generation.
|
|
129
|
+
*/
|
|
130
|
+
async submit(request, files, adapter, options = {}) {
|
|
131
|
+
const requestDigest = await this.computeRequestDigest(request, files, options.requestDigest);
|
|
132
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
133
|
+
if (!isAbortSignal(signal))
|
|
134
|
+
throw new OperationServiceError("invalid_signal", "Submission signal must be an AbortSignal.");
|
|
135
|
+
if (signal.aborted)
|
|
136
|
+
throw new OperationServiceError("operation_cancelled", "The operation was cancelled before submission.");
|
|
137
|
+
let loaded = await this.ensureCreated(request, requestDigest);
|
|
138
|
+
if (loaded.state.phase === "completed" && loaded.state.receipt !== undefined) {
|
|
139
|
+
return {
|
|
140
|
+
handle: await this.journal.handleFromState(loaded.state),
|
|
141
|
+
submission: submissionFromCompleted(loaded.state)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
let resolution;
|
|
145
|
+
try {
|
|
146
|
+
resolution = await this.resolveAndBindTarget(request, requestDigest, adapter, signal, loaded);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
// Creation is already durable at this point. A read-only target probe
|
|
150
|
+
// failure must therefore return the same resumable operation identity,
|
|
151
|
+
// not collapse into a transport error that loses the handle.
|
|
152
|
+
const current = await this.journal.load(request.operationId, requestDigest);
|
|
153
|
+
const handle = await this.journal.handleFromState(current.state);
|
|
154
|
+
await this.persistReturnedSubmissionBlocker(submissionFromTargetResolutionFailure(current.state, handle, error, signal));
|
|
155
|
+
const fresh = await this.journal.load(request.operationId, requestDigest);
|
|
156
|
+
const freshHandle = await this.journal.handleFromState(fresh.state);
|
|
157
|
+
return {
|
|
158
|
+
handle: freshHandle,
|
|
159
|
+
submission: submissionFromTargetResolutionFailure(fresh.state, freshHandle, error, signal)
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
loaded = resolution.loaded;
|
|
163
|
+
const targetBindingDigest = resolution.targetBindingDigest;
|
|
164
|
+
if (loaded.state.phase === "prepared" && adapter.staging !== undefined) {
|
|
165
|
+
const staging = await this.stageRequest(request, requestDigest, targetBindingDigest, adapter.staging, signal, options.deadlineAt);
|
|
166
|
+
if (staging !== undefined) {
|
|
167
|
+
await this.persistReturnedSubmissionBlocker(staging);
|
|
168
|
+
const fresh = await this.journal.load(request.operationId, requestDigest);
|
|
169
|
+
return { handle: await this.journal.handleFromState(fresh.state), submission: staging };
|
|
170
|
+
}
|
|
171
|
+
loaded = await this.journal.load(request.operationId, requestDigest);
|
|
172
|
+
}
|
|
173
|
+
const attachmentManifest = await Promise.all(files.map(async (file, ordinal) => ({
|
|
174
|
+
identityDigest: await this.journal.evidenceDigest("file-manifest", { ordinal, ...file }),
|
|
175
|
+
ordinal
|
|
176
|
+
})));
|
|
177
|
+
const expected = {
|
|
178
|
+
surface: request.surface,
|
|
179
|
+
targetBindingDigest,
|
|
180
|
+
configurationReceiptDigest: resolution.configurationReceiptDigest,
|
|
181
|
+
composerReceiptDigest: resolution.composerReceiptDigest,
|
|
182
|
+
attachmentManifest: {
|
|
183
|
+
count: attachmentManifest.length,
|
|
184
|
+
orderPolicy: "exact",
|
|
185
|
+
identities: attachmentManifest
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const send = uniqueAction(loaded.state, "send");
|
|
189
|
+
const handoff = uniqueAction(loaded.state, "file_handoff");
|
|
190
|
+
const operation = {
|
|
191
|
+
state: loaded.state,
|
|
192
|
+
handle: await this.journal.handleFromState(loaded.state),
|
|
193
|
+
actionIds: {
|
|
194
|
+
sendActionId: send?.actionId ?? randomUUID(),
|
|
195
|
+
...(files.length === 0 ? {} : { fileHandoffActionId: handoff?.actionId ?? randomUUID() })
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const submission = await runAtomicSubmission(operation, expected, this.submissionPorts(adapter.submission, loaded.state.target), { signal, ...(options.deadlineAt === undefined ? {} : { deadlineAt: options.deadlineAt }) });
|
|
199
|
+
await this.persistReturnedSubmissionBlocker(submission);
|
|
200
|
+
const fresh = await this.journal.load(request.operationId, requestDigest);
|
|
201
|
+
return { handle: await this.journal.handleFromState(fresh.state), submission };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Collect from a caller locator. It reloads the journal for every collector
|
|
205
|
+
* attempt and never calls the submission path. Completed receipts are
|
|
206
|
+
* returned directly, so a browser adapter is not needed for that case.
|
|
207
|
+
*/
|
|
208
|
+
async collect(handle, adapter, options = {}) {
|
|
209
|
+
const loaded = await this.loadForHandle(handle);
|
|
210
|
+
if (loaded.state.phase === "completed" && loaded.state.receipt !== undefined) {
|
|
211
|
+
return completedFromState(handle, loaded.state);
|
|
212
|
+
}
|
|
213
|
+
if (loaded.state.target === undefined) {
|
|
214
|
+
throw new OperationServiceError("target_binding_missing", "Collect requires a durable target binding.");
|
|
215
|
+
}
|
|
216
|
+
if (loaded.state.target.targetLifecycle === "new_pending") {
|
|
217
|
+
throw new OperationServiceError("target_establishment_required", "Collect requires durable provider identity establishment for a new target.");
|
|
218
|
+
}
|
|
219
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
220
|
+
const ports = {
|
|
221
|
+
readDurable: async ({ handle: requestedHandle }) => {
|
|
222
|
+
const current = await this.loadForHandle(requestedHandle);
|
|
223
|
+
if (current.state.target === undefined)
|
|
224
|
+
throw new OperationJournalError("operation_state_inconsistent", "Operation target binding is missing.");
|
|
225
|
+
const ownership = latestSubmissionOwnership(current.state);
|
|
226
|
+
if (ownership === undefined) {
|
|
227
|
+
throw new OperationServiceError("submission_witness_missing", "Collect cannot recover ownership without a durable causal baseline and witness.");
|
|
228
|
+
}
|
|
229
|
+
const targetBindingDigest = await this.targetBindingDigest(current.state);
|
|
230
|
+
const projectedWitness = ownershipWitnessFromDurable(ownership.witness);
|
|
231
|
+
const context = await adapter.collector.readContext({
|
|
232
|
+
operationId: current.state.operationId,
|
|
233
|
+
requestDigest: current.state.requestDigest,
|
|
234
|
+
targetBindingDigest,
|
|
235
|
+
submissionActionId: ownership.action.actionId,
|
|
236
|
+
submissionActionKind: ownership.action.kind,
|
|
237
|
+
submissionWitness: projectedWitness,
|
|
238
|
+
baseline: ownership.baseline.baseline,
|
|
239
|
+
signal
|
|
240
|
+
});
|
|
241
|
+
// The browser adapter may return a candidate observation for the
|
|
242
|
+
// current process, but it is never allowed to recreate durable
|
|
243
|
+
// ownership authority after restart. Project only the authenticated
|
|
244
|
+
// witness from journal state into the collect-only context.
|
|
245
|
+
return {
|
|
246
|
+
state: current.state,
|
|
247
|
+
// Keep browser-derived target evidence but project the causal
|
|
248
|
+
// action identity from authenticated state. A later Work steer
|
|
249
|
+
// owns its own delta; a legacy Send default cannot reclassify it.
|
|
250
|
+
binding: {
|
|
251
|
+
...context.binding,
|
|
252
|
+
operationId: current.state.operationId,
|
|
253
|
+
targetBindingDigest,
|
|
254
|
+
actionId: ownership.action.actionId,
|
|
255
|
+
actionKind: ownership.action.kind
|
|
256
|
+
},
|
|
257
|
+
// Never trust the adapter's current snapshot as the pre-Send
|
|
258
|
+
// baseline. The authenticated journal is the sole restart authority.
|
|
259
|
+
baseline: ownership.baseline.baseline,
|
|
260
|
+
submissionWitness: projectedWitness,
|
|
261
|
+
// A cursor captured under the original Send cannot be reused after
|
|
262
|
+
// a Work steer: its assistant branch/delta would classify the
|
|
263
|
+
// steer-owned user turn as foreign concurrent activity. The Work
|
|
264
|
+
// baseline+witness pair is the sole post-steer recovery anchor.
|
|
265
|
+
...(ownership.action.kind === "work_steer" || context.prior === undefined ? {} : { prior: context.prior })
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
observe: request => adapter.collector.observe(request),
|
|
269
|
+
sleep: (milliseconds, sleepSignal) => adapter.collector.sleep(milliseconds, sleepSignal),
|
|
270
|
+
persistProgress: request => this.persistProgress(request),
|
|
271
|
+
persistTerminal: request => this.persistTerminal(request, adapter.artifacts)
|
|
272
|
+
};
|
|
273
|
+
// New records carry the immutable capture contract. Fill omitted
|
|
274
|
+
// collect knobs from that contract so a restart-safe collect call does
|
|
275
|
+
// not accidentally fall back to the historical include/Markdown defaults.
|
|
276
|
+
const capturePolicy = loaded.state.capturePolicy;
|
|
277
|
+
const policyDefaults = capturePolicy === undefined
|
|
278
|
+
? {}
|
|
279
|
+
: {
|
|
280
|
+
...(options.responseContent === undefined ? { responseContent: capturePolicy.responseContent } : {}),
|
|
281
|
+
...(options.responseFormat === undefined ? { responseFormat: capturePolicy.responseFormat } : {})
|
|
282
|
+
};
|
|
283
|
+
const effectiveOptions = { ...policyDefaults, ...options };
|
|
284
|
+
return await collectOperation(handle, ports, effectiveOptions);
|
|
285
|
+
}
|
|
286
|
+
/** Browser-free state inspection. The adapter is intentionally not accepted. */
|
|
287
|
+
async inspect(handle) {
|
|
288
|
+
const loaded = await this.loadForHandle(handle);
|
|
289
|
+
return { state: loaded.state, handle: await this.journal.handleFromState(loaded.state) };
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Persist the one-way provider identity refinement for a genuine new
|
|
293
|
+
* target. This seam is browser-free: the adapter must first prove the exact
|
|
294
|
+
* post-Send user turn and provider identity, then call this method. It never
|
|
295
|
+
* allocates or guesses a conversation ID and it converges identical
|
|
296
|
+
* concurrent observations without appending a duplicate event.
|
|
297
|
+
*/
|
|
298
|
+
async establishTarget(request) {
|
|
299
|
+
request = validateTargetEstablishmentRequest(request);
|
|
300
|
+
if (request.postSendDeltaDigest === undefined) {
|
|
301
|
+
throw new OperationServiceError("target_establishment_delta_missing", "New-target establishment requires exact post-Send delta evidence.");
|
|
302
|
+
}
|
|
303
|
+
let loaded = await this.journal.load(request.operationId, request.requestDigest);
|
|
304
|
+
const currentHandle = await this.journal.handleFromState(loaded.state);
|
|
305
|
+
if (currentHandle.targetBindingDigest !== request.targetBindingDigest) {
|
|
306
|
+
throw new OperationServiceError("target_binding_mismatch", "Target establishment target digest does not match durable state.");
|
|
307
|
+
}
|
|
308
|
+
const target = loaded.state.target;
|
|
309
|
+
if (target === undefined) {
|
|
310
|
+
throw new OperationServiceError("target_binding_missing", "Target establishment requires a durable target anchor.");
|
|
311
|
+
}
|
|
312
|
+
if ((target.targetLifecycle ?? "fixed") === "fixed") {
|
|
313
|
+
throw new OperationServiceError("fixed_target_establishment", "A fixed target cannot be established as a new conversation.");
|
|
314
|
+
}
|
|
315
|
+
const send = loaded.state.actions[request.causalSendActionId];
|
|
316
|
+
if (send === undefined || send.kind !== "send") {
|
|
317
|
+
throw new OperationServiceError("target_establishment_send_missing", "Target establishment requires the causal original Send intent.");
|
|
318
|
+
}
|
|
319
|
+
if (send.outcome === "not_satisfied") {
|
|
320
|
+
throw new OperationServiceError("target_establishment_send_rejected", "Target establishment cannot follow a rejected Send intent.");
|
|
321
|
+
}
|
|
322
|
+
if (send.targetDigest !== request.targetBindingDigest) {
|
|
323
|
+
throw new OperationServiceError("target_establishment_target_mismatch", "Target establishment target digest does not match the causal Send intent.");
|
|
324
|
+
}
|
|
325
|
+
if (loaded.state.phase === "prepared" || loaded.state.phase === "handoff_pending" || loaded.state.phase === "completed") {
|
|
326
|
+
throw new OperationServiceError("target_establishment_phase_invalid", "Target establishment requires a durable Send lifecycle phase.");
|
|
327
|
+
}
|
|
328
|
+
const observedAt = request.observedAt ?? this.timestamp(loaded.state.updatedAt);
|
|
329
|
+
if (observedAt < send.intentAt) {
|
|
330
|
+
throw new OperationServiceError("target_establishment_before_send", "Target establishment cannot precede the durable Send intent.");
|
|
331
|
+
}
|
|
332
|
+
const establishment = {
|
|
333
|
+
targetBindingDigest: request.targetBindingDigest,
|
|
334
|
+
anchorDigest: request.anchorDigest,
|
|
335
|
+
causalSendActionId: request.causalSendActionId,
|
|
336
|
+
conversationId: request.conversationId,
|
|
337
|
+
canonicalThreadUrl: request.canonicalThreadUrl,
|
|
338
|
+
userTurnId: request.userTurnId,
|
|
339
|
+
userTurnEvidenceDigest: request.userTurnEvidenceDigest,
|
|
340
|
+
postSendDeltaDigest: request.postSendDeltaDigest,
|
|
341
|
+
evidenceDigest: request.evidenceDigest,
|
|
342
|
+
observedAt
|
|
343
|
+
};
|
|
344
|
+
const sameEstablishment = (state) => {
|
|
345
|
+
const existing = state.target?.targetEstablishment;
|
|
346
|
+
return state.target?.targetLifecycle === "new_established"
|
|
347
|
+
&& existing !== undefined
|
|
348
|
+
&& canonicalJson(existing) === canonicalJson(establishment);
|
|
349
|
+
};
|
|
350
|
+
if (sameEstablishment(loaded.state)) {
|
|
351
|
+
if (loaded.state.submissionWitness !== undefined) {
|
|
352
|
+
if (loaded.state.ownershipBaseline === undefined
|
|
353
|
+
|| !submissionWitnessMatchesEstablishment(loaded.state.submissionWitness, establishment, loaded.state.ownershipBaseline.baseline.snapshotDigest)) {
|
|
354
|
+
throw new OperationServiceError("submission_witness_conflict", "A durable submission witness conflicts with the established target evidence.");
|
|
355
|
+
}
|
|
356
|
+
return { state: loaded.state, handle: await this.journal.handleFromState(loaded.state) };
|
|
357
|
+
}
|
|
358
|
+
const withWitness = await this.appendSubmissionWitnessConvergent(request.operationId, request.requestDigest, this.submissionWitnessFromEstablishment(establishment, loaded.state.ownershipBaseline?.baseline.snapshotDigest, loaded.state.updatedAt));
|
|
359
|
+
return { state: withWitness.state, handle: await this.journal.handleFromState(withWitness.state) };
|
|
360
|
+
}
|
|
361
|
+
if (loaded.state.target?.targetLifecycle === "new_established") {
|
|
362
|
+
throw new OperationServiceError("target_establishment_conflict", "A different provider identity is already durably established.");
|
|
363
|
+
}
|
|
364
|
+
const event = {
|
|
365
|
+
type: "target_established",
|
|
366
|
+
establishment
|
|
367
|
+
};
|
|
368
|
+
try {
|
|
369
|
+
loaded = await this.appendConvergent(request.operationId, request.requestDigest, event, sameEstablishment);
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
// A concurrent writer may have established a different provider
|
|
373
|
+
// identity between our read and append. Surface that as an explicit
|
|
374
|
+
// fail-closed conflict instead of leaking a reducer/journal error.
|
|
375
|
+
try {
|
|
376
|
+
const observed = await this.journal.load(request.operationId, request.requestDigest);
|
|
377
|
+
if (observed.state.target?.targetLifecycle === "new_established") {
|
|
378
|
+
if (sameEstablishment(observed.state)) {
|
|
379
|
+
loaded = observed;
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
throw new OperationServiceError("target_establishment_conflict", "A different provider identity is already durably established.");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch (reconcileError) {
|
|
390
|
+
if (reconcileError instanceof OperationServiceError)
|
|
391
|
+
throw reconcileError;
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const withWitness = await this.appendSubmissionWitnessConvergent(request.operationId, request.requestDigest, this.submissionWitnessFromEstablishment(establishment, loaded.state.ownershipBaseline?.baseline.snapshotDigest, loaded.state.updatedAt));
|
|
396
|
+
const fresh = await this.journal.load(request.operationId, request.requestDigest);
|
|
397
|
+
if (!sameEstablishment(fresh.state) || fresh.state.submissionWitness === undefined || withWitness.state.submissionWitness === undefined) {
|
|
398
|
+
throw new OperationServiceError("target_establishment_indeterminate", "Target establishment was not durably validated after persistence.");
|
|
399
|
+
}
|
|
400
|
+
return { state: fresh.state, handle: await this.journal.handleFromState(fresh.state) };
|
|
401
|
+
}
|
|
402
|
+
/** Submit followed by collect with the same operation ID and handle. */
|
|
403
|
+
async run(request, files, adapter, options = {}) {
|
|
404
|
+
const submit = await this.submit(request, files, adapter, options);
|
|
405
|
+
if (submit.submission.kind !== "submitted"
|
|
406
|
+
&& submit.submission.kind !== "already_submitted"
|
|
407
|
+
&& submit.submission.kind !== "completed_receipt") {
|
|
408
|
+
return { submit };
|
|
409
|
+
}
|
|
410
|
+
const collected = await this.collect(submit.handle, adapter, options);
|
|
411
|
+
return { submit, collect: collected };
|
|
412
|
+
}
|
|
413
|
+
/** Run one operation-bound Stop or Work steer through the same journal. */
|
|
414
|
+
async control(request, adapter, options = {}) {
|
|
415
|
+
if (adapter.control === undefined) {
|
|
416
|
+
throw new OperationServiceError("control_unavailable", "The operation adapter does not expose control ports.");
|
|
417
|
+
}
|
|
418
|
+
const requestDigest = await this.journal.controlRequestDigest(request);
|
|
419
|
+
const ports = {
|
|
420
|
+
readParent: requestForParent => this.readControlParent(requestForParent),
|
|
421
|
+
observeTurn: requestForTurn => adapter.control.observeTurn(requestForTurn),
|
|
422
|
+
persistActionIntent: action => this.persistControlIntent(action),
|
|
423
|
+
executeOnce: execution => adapter.control.executeOnce(execution),
|
|
424
|
+
observePostcondition: postcondition => adapter.control.observePostcondition(postcondition),
|
|
425
|
+
...(adapter.control.postconditionRetry === undefined
|
|
426
|
+
? {}
|
|
427
|
+
: { postconditionRetry: adapter.control.postconditionRetry }),
|
|
428
|
+
persistReceipt: receipt => this.persistControlReceipt(receipt)
|
|
429
|
+
};
|
|
430
|
+
if (typeof adapter.control.prepareSteer === "function") {
|
|
431
|
+
ports.prepareSteer = requestForPrepare => adapter.control.prepareSteer(requestForPrepare);
|
|
432
|
+
}
|
|
433
|
+
if (typeof adapter.control.executeSteerPrepared === "function") {
|
|
434
|
+
ports.executeSteerPrepared = requestForExecute => adapter.control.executeSteerPrepared(requestForExecute);
|
|
435
|
+
}
|
|
436
|
+
if (typeof adapter.control.verifySteer === "function") {
|
|
437
|
+
ports.verifySteer = requestForVerify => adapter.control.verifySteer(requestForVerify);
|
|
438
|
+
}
|
|
439
|
+
if (typeof adapter.control.recoverSteer === "function") {
|
|
440
|
+
ports.recoverSteer = requestForRecover => adapter.control.recoverSteer(requestForRecover);
|
|
441
|
+
}
|
|
442
|
+
ports.persistSteerIntentAndBaseline = requestForPersistence => this.persistSteerIntentAndBaseline(requestForPersistence);
|
|
443
|
+
return await runOperationControl(request, requestDigest, ports, options);
|
|
444
|
+
}
|
|
445
|
+
async computeRequestDigest(request, files, provided) {
|
|
446
|
+
let computed;
|
|
447
|
+
try {
|
|
448
|
+
computed = await this.journal.submitRequestDigest(request, files);
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
if (readOwnErrorCode(error)?.startsWith("journal_rpc_") === true) {
|
|
452
|
+
throw this.serviceError(error, "journal_unavailable");
|
|
453
|
+
}
|
|
454
|
+
// Request canonicalization operates on caller-controlled input. Never
|
|
455
|
+
// forward native messages (or invoke message/string accessors) across
|
|
456
|
+
// the public operations boundary.
|
|
457
|
+
throw new OperationServiceError("invalid_operation_request", "The immutable operation request could not be canonicalized.");
|
|
458
|
+
}
|
|
459
|
+
if (provided !== undefined) {
|
|
460
|
+
if (!DIGEST_PATTERN.test(provided))
|
|
461
|
+
throw new OperationServiceError("invalid_request_digest", "requestDigest is not canonical.");
|
|
462
|
+
if (provided !== computed)
|
|
463
|
+
throw new OperationServiceError("operation_request_mismatch", "Provided requestDigest does not match the immutable request.");
|
|
464
|
+
}
|
|
465
|
+
return computed;
|
|
466
|
+
}
|
|
467
|
+
async ensureCreated(request, requestDigest) {
|
|
468
|
+
const createdAt = this.timestamp();
|
|
469
|
+
const event = {
|
|
470
|
+
type: "operation_created",
|
|
471
|
+
operationId: request.operationId,
|
|
472
|
+
requestDigest,
|
|
473
|
+
surface: request.surface,
|
|
474
|
+
createdAt,
|
|
475
|
+
capturePolicy: durableCapturePolicyFromRequest(request.capture)
|
|
476
|
+
};
|
|
477
|
+
try {
|
|
478
|
+
return await this.journal.create(event);
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
// A fault injector/process crash can occur after the creation record is
|
|
482
|
+
// durable but before append() returns. Reconcile that prefix safely.
|
|
483
|
+
try {
|
|
484
|
+
return await this.journal.load(request.operationId, requestDigest);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
throw this.serviceError(error, "journal_unavailable");
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async resolveAndBindTarget(request, requestDigest, adapter, signal, loaded) {
|
|
492
|
+
if (!adapter || typeof adapter.resolveTarget !== "function" || !adapter.submission || !adapter.collector) {
|
|
493
|
+
throw new OperationServiceError("adapter_incomplete", "Operation adapter is incomplete.");
|
|
494
|
+
}
|
|
495
|
+
const durableSubmit = Object.values(loaded.state.actions).some(action => action.kind === "send");
|
|
496
|
+
// Once a non-repeatable Send/steer intent is durable, retrying submit is
|
|
497
|
+
// observation-only. Do not make recovery depend on a fresh target probe
|
|
498
|
+
// or staging read that could itself be unavailable; the immutable target
|
|
499
|
+
// already recorded in the journal is the only target authority.
|
|
500
|
+
if (durableSubmit && loaded.state.target !== undefined) {
|
|
501
|
+
const targetBindingDigest = await this.targetBindingDigest(loaded.state);
|
|
502
|
+
const configurationReceiptDigest = loaded.state.target.configurationReceiptDigest
|
|
503
|
+
?? await this.journal.evidenceDigest("configuration-request", requestDigest);
|
|
504
|
+
const composerReceiptDigest = await this.journal.evidenceDigest("composer-request", requestDigest);
|
|
505
|
+
return { loaded, targetBindingDigest, configurationReceiptDigest, composerReceiptDigest };
|
|
506
|
+
}
|
|
507
|
+
if (loaded.state.target?.targetLifecycle === "new_pending") {
|
|
508
|
+
// A saved blank task without Send authority cannot be reconstructed by
|
|
509
|
+
// creating another task or replaying configuration/composer mutations.
|
|
510
|
+
throw new OperationServiceError("target_binding_mismatch", "The durable pending target requires read-only recovery authority.");
|
|
511
|
+
}
|
|
512
|
+
let resolution;
|
|
513
|
+
try {
|
|
514
|
+
resolution = await adapter.resolveTarget({
|
|
515
|
+
operationId: request.operationId,
|
|
516
|
+
requestDigest,
|
|
517
|
+
surface: request.surface,
|
|
518
|
+
target: request.target,
|
|
519
|
+
signal
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
// Preserve only the thrown value for the outer redacted classifier.
|
|
524
|
+
// Reading an adapter Error.message here could invoke a hostile accessor
|
|
525
|
+
// or retain provider-private request/account details.
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
validateTargetResolution(resolution);
|
|
529
|
+
const expectedConfigurationReceiptDigest = await this.journal.evidenceDigest("configuration-request", requestDigest);
|
|
530
|
+
const expectedComposerReceiptDigest = await this.journal.evidenceDigest("composer-request", requestDigest);
|
|
531
|
+
if (resolution.configurationReceiptDigest !== undefined
|
|
532
|
+
&& resolution.configurationReceiptDigest !== expectedConfigurationReceiptDigest) {
|
|
533
|
+
throw new OperationServiceError("configuration_drift", "Target resolution returned a configuration receipt outside the operation identity domain.");
|
|
534
|
+
}
|
|
535
|
+
if (resolution.composerReceiptDigest !== undefined
|
|
536
|
+
&& resolution.composerReceiptDigest !== expectedComposerReceiptDigest) {
|
|
537
|
+
throw new OperationServiceError("composer_drift", "Target resolution returned a composer receipt outside the operation identity domain.");
|
|
538
|
+
}
|
|
539
|
+
if (resolution.target.configurationReceiptDigest !== undefined
|
|
540
|
+
&& resolution.target.configurationReceiptDigest !== expectedConfigurationReceiptDigest) {
|
|
541
|
+
throw new OperationServiceError("configuration_drift", "Resolved target configuration receipt disagrees with the immutable request.");
|
|
542
|
+
}
|
|
543
|
+
const resolvedTarget = {
|
|
544
|
+
...resolution.target,
|
|
545
|
+
configurationReceiptDigest: expectedConfigurationReceiptDigest
|
|
546
|
+
};
|
|
547
|
+
let current = loaded;
|
|
548
|
+
if (current.state.target === undefined) {
|
|
549
|
+
current = await this.appendTarget(current, resolvedTarget);
|
|
550
|
+
}
|
|
551
|
+
else if (canonicalJson(current.state.target) !== canonicalJson(resolvedTarget)) {
|
|
552
|
+
throw new OperationServiceError("target_binding_mismatch", "The durable operation target binding is immutable.");
|
|
553
|
+
}
|
|
554
|
+
const targetBindingDigest = await this.targetBindingDigest(current.state);
|
|
555
|
+
const configurationReceiptDigest = current.state.target?.configurationReceiptDigest
|
|
556
|
+
?? expectedConfigurationReceiptDigest;
|
|
557
|
+
const composerReceiptDigest = expectedComposerReceiptDigest;
|
|
558
|
+
assertDigest(configurationReceiptDigest, "configurationReceiptDigest");
|
|
559
|
+
assertDigest(composerReceiptDigest, "composerReceiptDigest");
|
|
560
|
+
return { loaded: current, targetBindingDigest, configurationReceiptDigest, composerReceiptDigest };
|
|
561
|
+
}
|
|
562
|
+
async appendTarget(loaded, target) {
|
|
563
|
+
const event = {
|
|
564
|
+
type: "target_bound",
|
|
565
|
+
target,
|
|
566
|
+
observedAt: this.timestamp(loaded.state.updatedAt)
|
|
567
|
+
};
|
|
568
|
+
return this.appendConvergent(loaded.state.operationId, loaded.state.requestDigest, event, state => state.target !== undefined && canonicalJson(state.target) === canonicalJson(target));
|
|
569
|
+
}
|
|
570
|
+
submissionPorts(adapter, target) {
|
|
571
|
+
return {
|
|
572
|
+
observeStaging: (request) => adapter.observeStaging(request),
|
|
573
|
+
executeFileHandoffOnce: (request) => adapter.executeFileHandoffOnce(request),
|
|
574
|
+
observeAttachments: (request) => adapter.observeAttachments(request),
|
|
575
|
+
prepareSend: (request) => adapter.prepareSend(request),
|
|
576
|
+
persistPreparedSend: (request) => this.persistPreparedSend(request),
|
|
577
|
+
executePreparedSend: (request) => adapter.executePreparedSend(request),
|
|
578
|
+
verifyPreparedSend: (request) => adapter.verifyPreparedSend(request),
|
|
579
|
+
recoverSend: (request) => {
|
|
580
|
+
if (target === undefined || adapter.recoverAuthenticatedSend === undefined)
|
|
581
|
+
return adapter.recoverSend(request);
|
|
582
|
+
// runAtomicSubmission reaches this port only with the authenticated
|
|
583
|
+
// durable Send action and its persisted baseline. Never resolve the
|
|
584
|
+
// caller's original target:new request again after that boundary.
|
|
585
|
+
return adapter.recoverAuthenticatedSend(Object.freeze({ ...request,
|
|
586
|
+
target: Object.freeze({ ...target, evidenceProfile: Object.freeze({ ...target.evidenceProfile }) })
|
|
587
|
+
}));
|
|
588
|
+
},
|
|
589
|
+
executeFinalTabTransaction: (request) => adapter.executeFinalTabTransaction(request),
|
|
590
|
+
establishTarget: (request) => this.establishTarget(request),
|
|
591
|
+
persistActionIntent: (request) => this.persistActionIntent(request),
|
|
592
|
+
persistReceiptEvidence: (request) => this.persistSubmissionEvidence(request),
|
|
593
|
+
persistOwnershipBaseline: (request) => this.persistOwnershipBaseline(request)
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
async stageRequest(request, requestDigest, targetBindingDigest, adapter, signal, deadlineAt) {
|
|
597
|
+
for (const kind of stagingKinds(request)) {
|
|
598
|
+
const stage = {
|
|
599
|
+
operationId: request.operationId,
|
|
600
|
+
requestDigest,
|
|
601
|
+
targetBindingDigest,
|
|
602
|
+
actionId: await this.stagingActionId(request.operationId, requestDigest, kind),
|
|
603
|
+
kind,
|
|
604
|
+
desiredStateDigest: await this.journal.evidenceDigest("staging-desired", { requestDigest, kind })
|
|
605
|
+
};
|
|
606
|
+
const result = await runOperationStaging(stage, {
|
|
607
|
+
readCurrent: callback => adapter.readCurrent(callback),
|
|
608
|
+
persistIntent: intent => this.persistStagingIntent(intent.identity),
|
|
609
|
+
mutateOnce: callback => adapter.mutateOnce(callback),
|
|
610
|
+
observe: callback => adapter.observe(callback),
|
|
611
|
+
persistReceipt: receipt => this.persistStagingReceipt(receipt.receipt)
|
|
612
|
+
}, {
|
|
613
|
+
signal,
|
|
614
|
+
...(deadlineAt === undefined ? {} : { deadlineAt }),
|
|
615
|
+
now: this.now
|
|
616
|
+
});
|
|
617
|
+
if (result.kind !== "completed") {
|
|
618
|
+
return this.submissionFromStagingBlocker(request, requestDigest, targetBindingDigest, result);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
async stagingActionId(operationId, requestDigest, kind) {
|
|
624
|
+
// An unsettled intent is the only action ID that can be resumed. Settled
|
|
625
|
+
// set-to-value actions receive a fresh ID so a later exact observation may
|
|
626
|
+
// authorize one new reconciliation without reusing an earlier mutation.
|
|
627
|
+
// The caller still cannot create two concurrent mutations: target actor
|
|
628
|
+
// serialization plus the staging core's fresh post-intent observation
|
|
629
|
+
// makes the second action observation-only when the first already won.
|
|
630
|
+
const current = await this.journal.load(operationId, requestDigest);
|
|
631
|
+
const unsettled = Object.values(current.state.actions).filter(action => action.kind === kind && action.outcome === undefined);
|
|
632
|
+
if (unsettled.length > 1) {
|
|
633
|
+
throw new OperationServiceError("operation_state_corrupt", "Operation contains duplicate unsettled staging actions.");
|
|
634
|
+
}
|
|
635
|
+
return unsettled[0]?.actionId ?? randomUUID();
|
|
636
|
+
}
|
|
637
|
+
async persistStagingIntent(identity) {
|
|
638
|
+
const current = await this.journal.load(identity.operationId, identity.requestDigest);
|
|
639
|
+
const existing = current.state.actions[identity.actionId];
|
|
640
|
+
if (existing !== undefined) {
|
|
641
|
+
if (existing.kind !== identity.kind
|
|
642
|
+
|| existing.repeatPolicy !== "reconcile_set_to_value"
|
|
643
|
+
|| existing.requestDigest !== identity.requestDigest
|
|
644
|
+
|| existing.targetDigest !== identity.targetBindingDigest) {
|
|
645
|
+
throw new OperationServiceError("operation_state_corrupt", "Staging action identity conflicts with durable state.");
|
|
646
|
+
}
|
|
647
|
+
if (existing.outcome === undefined)
|
|
648
|
+
return { status: "existing_unsettled" };
|
|
649
|
+
return {
|
|
650
|
+
status: "existing_settled",
|
|
651
|
+
receipt: stagingReceiptFromAction(identity, existing)
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
await this.persistActionIntent({
|
|
655
|
+
operationId: identity.operationId,
|
|
656
|
+
requestDigest: identity.requestDigest,
|
|
657
|
+
surface: current.state.surface,
|
|
658
|
+
actionId: identity.actionId,
|
|
659
|
+
kind: identity.kind,
|
|
660
|
+
repeatPolicy: "reconcile_set_to_value",
|
|
661
|
+
targetBindingDigest: identity.targetBindingDigest
|
|
662
|
+
});
|
|
663
|
+
return { status: "created" };
|
|
664
|
+
}
|
|
665
|
+
async persistStagingReceipt(receipt) {
|
|
666
|
+
await this.appendActionReceiptConvergent(receipt.operationId, receipt.requestDigest, receipt.actionId, receipt.outcome, receipt.currentStateDigest ?? receipt.evidenceDigest, receipt.blockerCode);
|
|
667
|
+
}
|
|
668
|
+
submissionFromStagingBlocker(request, requestDigest, targetBindingDigest, result) {
|
|
669
|
+
const code = stagingBlockerCode(result.stagingKind, result.blocker.code);
|
|
670
|
+
return {
|
|
671
|
+
operationId: request.operationId,
|
|
672
|
+
requestDigest,
|
|
673
|
+
surface: request.surface,
|
|
674
|
+
targetBindingDigest,
|
|
675
|
+
kind: result.kind === "uncertain" ? "uncertain" : "blocked",
|
|
676
|
+
blocker: {
|
|
677
|
+
code,
|
|
678
|
+
observationRequired: result.blocker.observationRequired,
|
|
679
|
+
mutationBoundary: "none",
|
|
680
|
+
...(result.blocker.evidenceDigest === undefined ? {} : { evidenceDigest: result.blocker.evidenceDigest })
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
async persistActionIntent(request) {
|
|
685
|
+
if (!UUID_PATTERN.test(request.actionId) || !DIGEST_PATTERN.test(request.requestDigest) || !DIGEST_PATTERN.test(request.targetBindingDigest)) {
|
|
686
|
+
throw new OperationServiceError("invalid_action_intent", "Action intent identity is invalid.");
|
|
687
|
+
}
|
|
688
|
+
const durableRequestDigest = request.durableRequestDigest ?? request.requestDigest;
|
|
689
|
+
let afterIntent;
|
|
690
|
+
for (let attempt = 0; attempt < this.maxCasRetries; attempt += 1) {
|
|
691
|
+
const current = await this.journal.load(request.operationId, durableRequestDigest);
|
|
692
|
+
const existing = current.state.actions[request.actionId];
|
|
693
|
+
if (existing !== undefined) {
|
|
694
|
+
if (!actionMatchesIntent(existing, request)) {
|
|
695
|
+
throw new OperationServiceError("operation_state_corrupt", "Action ID is already bound to a different intent.");
|
|
696
|
+
}
|
|
697
|
+
afterIntent = current;
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
// This check must run again after every CAS conflict. Checking once and
|
|
701
|
+
// then using a generic convergent append allows two callers with
|
|
702
|
+
// different action IDs to serialize two intents for the same one-shot
|
|
703
|
+
// action. Reversible staging similarly permits at most one *unsettled*
|
|
704
|
+
// intent per kind; a later settled action may be reconciled again.
|
|
705
|
+
const conflict = conflictingActionForIntent(current.state, request);
|
|
706
|
+
if (conflict !== undefined) {
|
|
707
|
+
throw new OperationServiceError("action_already_intended", `A ${request.kind} intent already exists for this operation.`);
|
|
708
|
+
}
|
|
709
|
+
const event = {
|
|
710
|
+
type: "action_intent",
|
|
711
|
+
action: {
|
|
712
|
+
actionId: request.actionId,
|
|
713
|
+
kind: request.kind,
|
|
714
|
+
repeatPolicy: request.repeatPolicy,
|
|
715
|
+
requestDigest: request.requestDigest,
|
|
716
|
+
targetDigest: request.targetBindingDigest
|
|
717
|
+
},
|
|
718
|
+
intentAt: this.timestamp(current.state.updatedAt)
|
|
719
|
+
};
|
|
720
|
+
try {
|
|
721
|
+
afterIntent = await this.journal.append(request.operationId, current.state.revision, event);
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
catch (error) {
|
|
725
|
+
try {
|
|
726
|
+
const observed = await this.journal.load(request.operationId, durableRequestDigest);
|
|
727
|
+
const committed = observed.state.actions[request.actionId];
|
|
728
|
+
if (committed !== undefined) {
|
|
729
|
+
if (!actionMatchesIntent(committed, request)) {
|
|
730
|
+
throw new OperationServiceError("operation_state_corrupt", "Committed action intent conflicts with the caller identity.");
|
|
731
|
+
}
|
|
732
|
+
afterIntent = observed;
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
if (conflictingActionForIntent(observed.state, request) !== undefined) {
|
|
736
|
+
throw new OperationServiceError("action_already_intended", `A ${request.kind} intent already exists for this operation.`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
catch (reconcileError) {
|
|
740
|
+
if (reconcileError instanceof OperationServiceError)
|
|
741
|
+
throw reconcileError;
|
|
742
|
+
// Preserve the original append error when the committed prefix
|
|
743
|
+
// cannot be authenticated. Never reinterpret corruption as a retry.
|
|
744
|
+
}
|
|
745
|
+
if (isRevisionConflict(error))
|
|
746
|
+
continue;
|
|
747
|
+
throw this.serviceError(error, "journal_unavailable");
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (afterIntent === undefined) {
|
|
751
|
+
throw new OperationServiceError("journal_conflict", "Concurrent operation writers did not converge within the retry bound.");
|
|
752
|
+
}
|
|
753
|
+
if (request.kind === "file_handoff" && afterIntent.state.phase === "prepared") {
|
|
754
|
+
await this.appendPhaseConvergent(request.operationId, durableRequestDigest, "handoff_pending", request.actionId);
|
|
755
|
+
}
|
|
756
|
+
else if (request.kind === "send" && afterIntent.state.phase === "ready") {
|
|
757
|
+
await this.appendPhaseConvergent(request.operationId, durableRequestDigest, "send_pending", request.actionId);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Persist the non-repeatable Send intent and its complete ownership anchor
|
|
762
|
+
* in one event. `executeAllowed` is true only when this exact invocation's
|
|
763
|
+
* append and adjacent phase transition both returned successfully. Any
|
|
764
|
+
* commit-then-throw or concurrent convergence is observation-only.
|
|
765
|
+
*/
|
|
766
|
+
async persistPreparedSend(request) {
|
|
767
|
+
if (!UUID_PATTERN.test(request.operationId)
|
|
768
|
+
|| !UUID_PATTERN.test(request.actionId)
|
|
769
|
+
|| !DIGEST_PATTERN.test(request.requestDigest)
|
|
770
|
+
|| !DIGEST_PATTERN.test(request.durableRequestDigest)
|
|
771
|
+
|| !DIGEST_PATTERN.test(request.targetBindingDigest)
|
|
772
|
+
|| request.requestDigest !== request.durableRequestDigest
|
|
773
|
+
|| request.kind !== "send"
|
|
774
|
+
|| request.repeatPolicy !== "observe_only_after_intent"
|
|
775
|
+
|| (request.surface !== "chat" && request.surface !== "work")) {
|
|
776
|
+
return { status: "not_committed", blockerCode: "operation_state_corrupt" };
|
|
777
|
+
}
|
|
778
|
+
let baselineSnapshot;
|
|
779
|
+
try {
|
|
780
|
+
baselineSnapshot = JSON.parse(canonicalJson(request.baseline));
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
return { status: "not_committed", blockerCode: "operation_state_corrupt" };
|
|
784
|
+
}
|
|
785
|
+
const intentRequest = {
|
|
786
|
+
operationId: request.operationId,
|
|
787
|
+
requestDigest: request.requestDigest,
|
|
788
|
+
durableRequestDigest: request.durableRequestDigest,
|
|
789
|
+
surface: request.surface,
|
|
790
|
+
actionId: request.actionId,
|
|
791
|
+
kind: "send",
|
|
792
|
+
repeatPolicy: "observe_only_after_intent",
|
|
793
|
+
targetBindingDigest: request.targetBindingDigest
|
|
794
|
+
};
|
|
795
|
+
for (let attempt = 0; attempt < this.maxCasRetries; attempt += 1) {
|
|
796
|
+
let current;
|
|
797
|
+
try {
|
|
798
|
+
current = await this.journal.load(request.operationId, request.durableRequestDigest);
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
return { status: "uncertain" };
|
|
802
|
+
}
|
|
803
|
+
if (current.state.surface !== request.surface
|
|
804
|
+
|| current.state.target === undefined
|
|
805
|
+
|| await this.targetBindingDigest(current.state) !== request.targetBindingDigest) {
|
|
806
|
+
return { status: "not_committed", blockerCode: "target_binding_mismatch" };
|
|
807
|
+
}
|
|
808
|
+
const baseline = {
|
|
809
|
+
schemaVersion: OPERATION_OWNERSHIP_BASELINE_SCHEMA_VERSION,
|
|
810
|
+
operationId: request.operationId,
|
|
811
|
+
requestDigest: request.requestDigest,
|
|
812
|
+
targetBindingDigest: request.targetBindingDigest,
|
|
813
|
+
actionId: request.actionId,
|
|
814
|
+
baseline: baselineSnapshot,
|
|
815
|
+
observedAt: current.state.updatedAt
|
|
816
|
+
};
|
|
817
|
+
try {
|
|
818
|
+
assertOwnershipBaselineShape(baseline);
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
return { status: "not_committed", blockerCode: "operation_state_corrupt" };
|
|
822
|
+
}
|
|
823
|
+
const existing = current.state.actions[request.actionId];
|
|
824
|
+
if (existing !== undefined) {
|
|
825
|
+
const durableBaseline = current.state.ownershipBaselines?.[request.actionId]
|
|
826
|
+
?? (existing.kind === "send" ? current.state.ownershipBaseline : undefined);
|
|
827
|
+
if (!actionMatchesIntent(existing, intentRequest)
|
|
828
|
+
|| durableBaseline === undefined
|
|
829
|
+
|| canonicalJson(durableBaseline) !== canonicalJson({
|
|
830
|
+
...baseline,
|
|
831
|
+
observedAt: durableBaseline.observedAt
|
|
832
|
+
})) {
|
|
833
|
+
return { status: "not_committed", blockerCode: "operation_state_corrupt" };
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
if (current.state.phase === "ready") {
|
|
837
|
+
await this.appendPhaseConvergent(request.operationId, request.durableRequestDigest, "send_pending", request.actionId);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
return { status: "uncertain" };
|
|
842
|
+
}
|
|
843
|
+
return { status: "committed", executeAllowed: false };
|
|
844
|
+
}
|
|
845
|
+
if (conflictingActionForIntent(current.state, intentRequest) !== undefined) {
|
|
846
|
+
// A concurrent writer used another random action ID. The caller must
|
|
847
|
+
// reload the operation so recovery uses that exact durable identity.
|
|
848
|
+
return { status: "not_committed", blockerCode: "stale_handle" };
|
|
849
|
+
}
|
|
850
|
+
const event = {
|
|
851
|
+
type: "action_prepared",
|
|
852
|
+
action: {
|
|
853
|
+
actionId: request.actionId,
|
|
854
|
+
kind: "send",
|
|
855
|
+
repeatPolicy: "observe_only_after_intent",
|
|
856
|
+
requestDigest: request.requestDigest,
|
|
857
|
+
targetDigest: request.targetBindingDigest
|
|
858
|
+
},
|
|
859
|
+
intentAt: current.state.updatedAt,
|
|
860
|
+
baseline
|
|
861
|
+
};
|
|
862
|
+
try {
|
|
863
|
+
await this.journal.append(request.operationId, current.state.revision, event);
|
|
864
|
+
await this.appendPhaseConvergent(request.operationId, request.durableRequestDigest, "send_pending", request.actionId);
|
|
865
|
+
return { status: "committed", executeAllowed: true };
|
|
866
|
+
}
|
|
867
|
+
catch (error) {
|
|
868
|
+
// A committed append whose acknowledgement was lost is safe to
|
|
869
|
+
// recover, but never safe to execute from this invocation.
|
|
870
|
+
try {
|
|
871
|
+
const observed = await this.journal.load(request.operationId, request.durableRequestDigest);
|
|
872
|
+
const action = observed.state.actions[request.actionId];
|
|
873
|
+
const durableBaseline = observed.state.ownershipBaselines?.[request.actionId]
|
|
874
|
+
?? (action?.kind === "send" ? observed.state.ownershipBaseline : undefined);
|
|
875
|
+
if (action !== undefined
|
|
876
|
+
&& actionMatchesIntent(action, intentRequest)
|
|
877
|
+
&& durableBaseline !== undefined
|
|
878
|
+
&& canonicalJson(durableBaseline) === canonicalJson({ ...baseline, observedAt: durableBaseline.observedAt })) {
|
|
879
|
+
try {
|
|
880
|
+
if (observed.state.phase === "ready") {
|
|
881
|
+
await this.appendPhaseConvergent(request.operationId, request.durableRequestDigest, "send_pending", request.actionId);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
return { status: "uncertain" };
|
|
886
|
+
}
|
|
887
|
+
return { status: "committed", executeAllowed: false };
|
|
888
|
+
}
|
|
889
|
+
if (conflictingActionForIntent(observed.state, intentRequest) !== undefined) {
|
|
890
|
+
return { status: "not_committed", blockerCode: "stale_handle" };
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
return { status: "uncertain" };
|
|
895
|
+
}
|
|
896
|
+
if (isRevisionConflict(error))
|
|
897
|
+
continue;
|
|
898
|
+
return { status: "uncertain" };
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return { status: "uncertain" };
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Atomically fence a Work-steer action with its complete pre-steer
|
|
905
|
+
* ownership anchor. The action_prepared event is the sole mutation
|
|
906
|
+
* authorization boundary: an invocation that observes or converges a
|
|
907
|
+
* committed prefix is permanently recovery-only. A different unresolved
|
|
908
|
+
* action receives an explicit typed block; a previously settled action does
|
|
909
|
+
* not prevent a new caller-owned steer.
|
|
910
|
+
*/
|
|
911
|
+
async persistSteerIntentAndBaseline(request) {
|
|
912
|
+
const schemaVersion = CONTROL_COORDINATOR_SCHEMA_VERSION;
|
|
913
|
+
if (request.schemaVersion !== schemaVersion
|
|
914
|
+
|| request.action !== "steer"
|
|
915
|
+
|| !UUID_PATTERN.test(request.parentOperationId)
|
|
916
|
+
|| !UUID_PATTERN.test(request.controlActionId)
|
|
917
|
+
|| !DIGEST_PATTERN.test(request.parentRequestDigest)
|
|
918
|
+
|| !DIGEST_PATTERN.test(request.parentTargetBindingDigest)
|
|
919
|
+
|| !DIGEST_PATTERN.test(request.requestDigest)
|
|
920
|
+
|| request.requestDigest === request.parentRequestDigest
|
|
921
|
+
|| !DIGEST_PATTERN.test(request.baselineSnapshotDigest)
|
|
922
|
+
|| !DIGEST_PATTERN.test(request.preparedDigest)
|
|
923
|
+
|| !isSafeOpaqueId(request.expectedAssistantTurnId)
|
|
924
|
+
|| !isSafeOpaqueId(request.assistantBranchId)
|
|
925
|
+
|| !isSafeOpaqueId(request.assistantParentTurnId)) {
|
|
926
|
+
throw new OperationServiceError("operation_state_corrupt", "Work-steer persistence identity is invalid.");
|
|
927
|
+
}
|
|
928
|
+
let material;
|
|
929
|
+
try {
|
|
930
|
+
material = controlSteerPreparedDigestMaterial({
|
|
931
|
+
parentOperationId: request.parentOperationId,
|
|
932
|
+
parentRequestDigest: request.parentRequestDigest,
|
|
933
|
+
parentTargetBindingDigest: request.parentTargetBindingDigest,
|
|
934
|
+
controlActionId: request.controlActionId,
|
|
935
|
+
expectedAssistantTurnId: request.expectedAssistantTurnId,
|
|
936
|
+
assistantBranchId: request.assistantBranchId,
|
|
937
|
+
assistantParentTurnId: request.assistantParentTurnId,
|
|
938
|
+
baselineSnapshotDigest: request.baselineSnapshotDigest,
|
|
939
|
+
baseline: request.baseline
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
throw new OperationServiceError("operation_state_corrupt", "Work-steer persistence baseline is invalid.");
|
|
944
|
+
}
|
|
945
|
+
const expectedPreparedDigest = await this.journal.evidenceDigest("work-steer-prepared", material);
|
|
946
|
+
if (expectedPreparedDigest !== request.preparedDigest) {
|
|
947
|
+
throw new OperationServiceError("operation_request_mismatch", "Work-steer prepared evidence does not match the journal identity.");
|
|
948
|
+
}
|
|
949
|
+
const intentRequest = {
|
|
950
|
+
operationId: request.parentOperationId,
|
|
951
|
+
requestDigest: request.requestDigest,
|
|
952
|
+
durableRequestDigest: request.parentRequestDigest,
|
|
953
|
+
surface: "work",
|
|
954
|
+
actionId: request.controlActionId,
|
|
955
|
+
kind: "work_steer",
|
|
956
|
+
repeatPolicy: "observe_only_after_intent",
|
|
957
|
+
targetBindingDigest: request.parentTargetBindingDigest
|
|
958
|
+
};
|
|
959
|
+
for (let attempt = 0; attempt < this.maxCasRetries; attempt += 1) {
|
|
960
|
+
let current;
|
|
961
|
+
try {
|
|
962
|
+
current = await this.journal.load(request.parentOperationId, request.parentRequestDigest);
|
|
963
|
+
}
|
|
964
|
+
catch (error) {
|
|
965
|
+
throw this.serviceError(error, "journal_unavailable");
|
|
966
|
+
}
|
|
967
|
+
if (current.state.target === undefined
|
|
968
|
+
|| current.state.surface !== "work"
|
|
969
|
+
|| await this.targetBindingDigest(current.state) !== request.parentTargetBindingDigest) {
|
|
970
|
+
throw new OperationServiceError("target_binding_mismatch", "Work-steer target or surface does not match durable state.");
|
|
971
|
+
}
|
|
972
|
+
const existing = current.state.actions[request.controlActionId];
|
|
973
|
+
if (existing !== undefined) {
|
|
974
|
+
if (!actionMatchesIntent(existing, intentRequest)) {
|
|
975
|
+
throw new OperationServiceError("operation_state_corrupt", "Work-steer action ID is already bound to a different intent.");
|
|
976
|
+
}
|
|
977
|
+
const durableBaseline = current.state.ownershipBaselines?.[request.controlActionId];
|
|
978
|
+
if (durableBaseline === undefined || canonicalJson(durableBaseline) !== canonicalJson({
|
|
979
|
+
schemaVersion: OPERATION_OWNERSHIP_BASELINE_SCHEMA_VERSION,
|
|
980
|
+
operationId: request.parentOperationId,
|
|
981
|
+
requestDigest: request.parentRequestDigest,
|
|
982
|
+
targetBindingDigest: request.parentTargetBindingDigest,
|
|
983
|
+
actionId: request.controlActionId,
|
|
984
|
+
baseline: material.baseline,
|
|
985
|
+
observedAt: durableBaseline?.observedAt
|
|
986
|
+
})) {
|
|
987
|
+
throw new OperationServiceError("operation_state_corrupt", "Durable Work-steer baseline does not match the prepared request.");
|
|
988
|
+
}
|
|
989
|
+
const durablePrepared = await this.reconstructSteerIntent(current.state, request.controlActionId, request.parentRequestDigest, request.parentTargetBindingDigest, request.requestDigest, request.expectedAssistantTurnId);
|
|
990
|
+
if (durablePrepared.preparedDigest !== request.preparedDigest) {
|
|
991
|
+
throw new OperationServiceError("operation_request_mismatch", "Durable Work-steer prepared evidence conflicts with the request.");
|
|
992
|
+
}
|
|
993
|
+
return { schemaVersion, disposition: "same_action_recovery" };
|
|
994
|
+
}
|
|
995
|
+
const conflicting = Object.values(current.state.actions).find(action => {
|
|
996
|
+
if (action.kind !== "work_steer" || action.actionId === request.controlActionId)
|
|
997
|
+
return false;
|
|
998
|
+
return priorWorkSteerDisposition(current.state, action) === "unresolved";
|
|
999
|
+
});
|
|
1000
|
+
if (conflicting !== undefined) {
|
|
1001
|
+
// A different action ID is still pending or uncertain. The caller
|
|
1002
|
+
// must not append a second fence or attempt a mutation concurrently.
|
|
1003
|
+
return {
|
|
1004
|
+
schemaVersion,
|
|
1005
|
+
disposition: "blocked",
|
|
1006
|
+
blockerCode: "provider_concurrency_unsupported"
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
const intentAt = current.state.updatedAt;
|
|
1010
|
+
const baseline = {
|
|
1011
|
+
schemaVersion: OPERATION_OWNERSHIP_BASELINE_SCHEMA_VERSION,
|
|
1012
|
+
operationId: request.parentOperationId,
|
|
1013
|
+
requestDigest: request.parentRequestDigest,
|
|
1014
|
+
targetBindingDigest: request.parentTargetBindingDigest,
|
|
1015
|
+
actionId: request.controlActionId,
|
|
1016
|
+
baseline: material.baseline,
|
|
1017
|
+
observedAt: intentAt
|
|
1018
|
+
};
|
|
1019
|
+
try {
|
|
1020
|
+
assertOwnershipBaselineShape(baseline);
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
throw new OperationServiceError("operation_state_corrupt", "Work-steer ownership baseline is invalid.");
|
|
1024
|
+
}
|
|
1025
|
+
const event = {
|
|
1026
|
+
type: "action_prepared",
|
|
1027
|
+
action: {
|
|
1028
|
+
actionId: request.controlActionId,
|
|
1029
|
+
kind: "work_steer",
|
|
1030
|
+
repeatPolicy: "observe_only_after_intent",
|
|
1031
|
+
requestDigest: request.requestDigest,
|
|
1032
|
+
targetDigest: request.parentTargetBindingDigest
|
|
1033
|
+
},
|
|
1034
|
+
intentAt,
|
|
1035
|
+
baseline
|
|
1036
|
+
};
|
|
1037
|
+
try {
|
|
1038
|
+
const committed = await this.journal.append(request.parentOperationId, current.state.revision, event);
|
|
1039
|
+
const durableBaseline = committed.state.ownershipBaselines?.[request.controlActionId];
|
|
1040
|
+
const durableAction = committed.state.actions[request.controlActionId];
|
|
1041
|
+
if (durableAction === undefined
|
|
1042
|
+
|| !actionMatchesIntent(durableAction, intentRequest)
|
|
1043
|
+
|| durableBaseline === undefined
|
|
1044
|
+
|| canonicalJson(durableBaseline) !== canonicalJson(baseline)) {
|
|
1045
|
+
throw new OperationServiceError("operation_state_corrupt", "Work-steer action_prepared append did not converge to the requested identity.");
|
|
1046
|
+
}
|
|
1047
|
+
// This is the only path that may authorize the request-local execute
|
|
1048
|
+
// port. A successful append acknowledgement proves this invocation
|
|
1049
|
+
// acquired the journal fence; no subsequent convergence path does.
|
|
1050
|
+
return { schemaVersion, disposition: "acquired" };
|
|
1051
|
+
}
|
|
1052
|
+
catch (error) {
|
|
1053
|
+
try {
|
|
1054
|
+
const observed = await this.journal.load(request.parentOperationId, request.parentRequestDigest);
|
|
1055
|
+
const committedAction = observed.state.actions[request.controlActionId];
|
|
1056
|
+
if (committedAction !== undefined) {
|
|
1057
|
+
if (!actionMatchesIntent(committedAction, intentRequest)) {
|
|
1058
|
+
throw new OperationServiceError("operation_state_corrupt", "Committed Work-steer action conflicts with the caller identity.");
|
|
1059
|
+
}
|
|
1060
|
+
const committedBaseline = observed.state.ownershipBaselines?.[request.controlActionId];
|
|
1061
|
+
if (committedBaseline === undefined || canonicalJson(committedBaseline) !== canonicalJson({
|
|
1062
|
+
...baseline,
|
|
1063
|
+
observedAt: committedBaseline.observedAt
|
|
1064
|
+
})) {
|
|
1065
|
+
throw new OperationServiceError("operation_state_corrupt", "Committed Work-steer baseline is not authenticated.");
|
|
1066
|
+
}
|
|
1067
|
+
// Append may have committed before its acknowledgement failed.
|
|
1068
|
+
// That invocation is recovery-only and must never execute.
|
|
1069
|
+
return { schemaVersion, disposition: "same_action_recovery" };
|
|
1070
|
+
}
|
|
1071
|
+
const conflicting = Object.values(observed.state.actions).find(action => {
|
|
1072
|
+
if (action.kind !== "work_steer" || action.actionId === request.controlActionId)
|
|
1073
|
+
return false;
|
|
1074
|
+
return priorWorkSteerDisposition(observed.state, action) === "unresolved";
|
|
1075
|
+
});
|
|
1076
|
+
if (conflicting !== undefined) {
|
|
1077
|
+
return {
|
|
1078
|
+
schemaVersion,
|
|
1079
|
+
disposition: "blocked",
|
|
1080
|
+
blockerCode: "provider_concurrency_unsupported"
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
catch (reconcileError) {
|
|
1085
|
+
if (reconcileError instanceof OperationServiceError)
|
|
1086
|
+
throw reconcileError;
|
|
1087
|
+
}
|
|
1088
|
+
if (isRevisionConflict(error))
|
|
1089
|
+
continue;
|
|
1090
|
+
throw this.serviceError(error, "journal_unavailable");
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
throw new OperationServiceError("journal_conflict", "Concurrent Work-steer writers did not converge within the retry bound.");
|
|
1094
|
+
}
|
|
1095
|
+
async persistSubmissionEvidence(request) {
|
|
1096
|
+
if (request.kind === "phase") {
|
|
1097
|
+
if (request.actionOutcome !== undefined && request.actionId !== undefined) {
|
|
1098
|
+
await this.appendActionReceiptConvergent(request.operationId, request.requestDigest, request.actionId, request.actionOutcome, request.evidenceDigest);
|
|
1099
|
+
}
|
|
1100
|
+
if (request.phase === "ready") {
|
|
1101
|
+
await this.appendPhaseConvergent(request.operationId, request.requestDigest, "ready", request.actionId, request.evidenceDigest);
|
|
1102
|
+
}
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
if (request.kind === "receipt") {
|
|
1106
|
+
let loaded = await this.journal.load(request.operationId, request.requestDigest);
|
|
1107
|
+
const send = originalSendAction(loaded.state);
|
|
1108
|
+
if (send === undefined)
|
|
1109
|
+
throw new OperationServiceError("operation_state_inconsistent", "Receipt evidence has no durable submission intent.");
|
|
1110
|
+
if (loaded.state.submissionWitness !== undefined) {
|
|
1111
|
+
if (loaded.state.ownershipBaseline === undefined
|
|
1112
|
+
|| !submissionWitnessMatchesReceipt(loaded.state.submissionWitness, request, send.actionId, loaded.state.ownershipBaseline.baseline.snapshotDigest)) {
|
|
1113
|
+
throw new OperationServiceError("submission_witness_conflict", "Receipt evidence conflicts with the durable submission witness.");
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
else {
|
|
1117
|
+
if (loaded.state.ownershipBaseline === undefined) {
|
|
1118
|
+
throw new OperationServiceError("ownership_baseline_missing", "Receipt evidence requires the durable pre-Send ownership baseline.");
|
|
1119
|
+
}
|
|
1120
|
+
const witness = this.submissionWitnessFromReceipt(request, send.actionId, loaded.state.ownershipBaseline.baseline.snapshotDigest, loaded.state.updatedAt);
|
|
1121
|
+
loaded = await this.appendSubmissionWitnessConvergent(request.operationId, request.requestDigest, witness);
|
|
1122
|
+
}
|
|
1123
|
+
if (loaded.state.phase === "ready") {
|
|
1124
|
+
if (send.outcome === undefined) {
|
|
1125
|
+
loaded = await this.appendPhaseConvergent(request.operationId, request.requestDigest, "send_pending", send.actionId);
|
|
1126
|
+
}
|
|
1127
|
+
else {
|
|
1128
|
+
loaded = await this.appendPhaseConvergent(request.operationId, request.requestDigest, "uncertain", send.actionId, request.evidenceDigest);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
if (send.outcome === undefined) {
|
|
1132
|
+
await this.appendActionReceiptConvergent(request.operationId, request.requestDigest, send.actionId, "satisfied", request.evidenceDigest);
|
|
1133
|
+
}
|
|
1134
|
+
await this.appendPhaseConvergent(request.operationId, request.requestDigest, "submitted", send.actionId, request.evidenceDigest);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
const state = await this.journal.load(request.operationId, request.requestDigest);
|
|
1138
|
+
const messageDigest = await this.journal.evidenceDigest("blocker", {
|
|
1139
|
+
code: request.blocker.code,
|
|
1140
|
+
evidenceDigest: request.blocker.evidenceDigest
|
|
1141
|
+
});
|
|
1142
|
+
const blockerEvent = {
|
|
1143
|
+
type: "blocker_observed",
|
|
1144
|
+
blocker: {
|
|
1145
|
+
code: request.blocker.code,
|
|
1146
|
+
messageDigest,
|
|
1147
|
+
recoverable: request.blocker.observationRequired,
|
|
1148
|
+
observedAt: this.timestamp(state.state.updatedAt)
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
await this.appendConvergent(request.operationId, request.requestDigest, blockerEvent, current => current.lastBlocker?.code === request.blocker.code && current.lastBlocker.messageDigest === messageDigest);
|
|
1152
|
+
const after = await this.journal.load(request.operationId, request.requestDigest);
|
|
1153
|
+
if (after.state.phase !== "uncertain"
|
|
1154
|
+
&& after.state.phase !== "completed"
|
|
1155
|
+
&& request.blocker.observationRequired
|
|
1156
|
+
&& request.blocker.mutationBoundary !== "none"
|
|
1157
|
+
&& after.state.mutationBoundary !== "none"
|
|
1158
|
+
&& UNCERTAIN_SUBMISSION_BLOCKERS.has(request.blocker.code)) {
|
|
1159
|
+
await this.appendPhaseConvergent(request.operationId, request.requestDigest, "uncertain", undefined, request.blocker.evidenceDigest);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
async persistReturnedSubmissionBlocker(result) {
|
|
1163
|
+
if (result.kind !== "blocked" && result.kind !== "cancelled" && result.kind !== "uncertain")
|
|
1164
|
+
return;
|
|
1165
|
+
try {
|
|
1166
|
+
const current = await this.journal.load(result.operationId, result.requestDigest);
|
|
1167
|
+
await this.persistSubmissionEvidence({
|
|
1168
|
+
kind: "blocker",
|
|
1169
|
+
operationId: result.operationId,
|
|
1170
|
+
requestDigest: result.requestDigest,
|
|
1171
|
+
surface: result.surface,
|
|
1172
|
+
phase: current.state.phase,
|
|
1173
|
+
mutationBoundary: current.state.mutationBoundary,
|
|
1174
|
+
...(result.targetBindingDigest === undefined ? {} : { targetBindingDigest: result.targetBindingDigest }),
|
|
1175
|
+
blocker: result.blocker
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
catch {
|
|
1179
|
+
// The browser result still carries the blocker and every non-repeatable
|
|
1180
|
+
// intent was persisted before mutation. A diagnostic append failure must
|
|
1181
|
+
// not manufacture a different browser outcome or authorize a retry.
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Append the one immutable post-Send ownership witness. This is deliberately
|
|
1186
|
+
* separate from action receipts and phase events: callers must be able to
|
|
1187
|
+
* replay a crash prefix and distinguish "Send was invoked" from "the exact
|
|
1188
|
+
* operation-owned user turn was proven" without consulting the browser.
|
|
1189
|
+
*/
|
|
1190
|
+
async appendSubmissionWitnessConvergent(operationId, requestDigest, witness) {
|
|
1191
|
+
validateSubmissionWitnessInput(witness);
|
|
1192
|
+
let current = await this.journal.load(operationId, requestDigest);
|
|
1193
|
+
const action = current.state.actions[witness.actionId];
|
|
1194
|
+
if (action === undefined || action.kind !== witness.actionKind) {
|
|
1195
|
+
throw new OperationServiceError("submission_witness_action_missing", "Submission witness does not name its durable causal action.");
|
|
1196
|
+
}
|
|
1197
|
+
if (action.targetDigest !== witness.targetBindingDigest) {
|
|
1198
|
+
throw new OperationServiceError("submission_witness_target_mismatch", "Submission witness target does not match its causal action.");
|
|
1199
|
+
}
|
|
1200
|
+
if (action.outcome === "not_satisfied" || action.outcome === "uncertain") {
|
|
1201
|
+
throw new OperationServiceError("submission_witness_action_unproven", "Submission witness cannot follow an unsatisfied or uncertain causal action.");
|
|
1202
|
+
}
|
|
1203
|
+
const durableBaseline = current.state.ownershipBaselines?.[witness.actionId]
|
|
1204
|
+
?? (action.kind === "send" && current.state.ownershipBaseline?.actionId === witness.actionId
|
|
1205
|
+
? current.state.ownershipBaseline
|
|
1206
|
+
: undefined);
|
|
1207
|
+
if (durableBaseline === undefined) {
|
|
1208
|
+
throw new OperationServiceError("ownership_baseline_missing", "A submission witness requires the durable baseline for its causal action.");
|
|
1209
|
+
}
|
|
1210
|
+
if (durableBaseline.operationId !== operationId
|
|
1211
|
+
|| durableBaseline.requestDigest !== requestDigest
|
|
1212
|
+
|| durableBaseline.targetBindingDigest !== witness.targetBindingDigest
|
|
1213
|
+
|| durableBaseline.actionId !== witness.actionId
|
|
1214
|
+
|| witness.baselineSnapshotDigest !== durableBaseline.baseline.snapshotDigest) {
|
|
1215
|
+
throw new OperationServiceError("submission_witness_baseline_mismatch", "Submission witness is not bound to its durable causal baseline.");
|
|
1216
|
+
}
|
|
1217
|
+
const existing = submissionWitnessForAction(current.state, action);
|
|
1218
|
+
if (existing !== undefined) {
|
|
1219
|
+
if (canonicalJson(existing) !== canonicalJson(witness)) {
|
|
1220
|
+
throw new OperationServiceError("submission_witness_conflict", "A different immutable submission witness is already durable for this action.");
|
|
1221
|
+
}
|
|
1222
|
+
return current;
|
|
1223
|
+
}
|
|
1224
|
+
const event = {
|
|
1225
|
+
type: "submission_witness",
|
|
1226
|
+
witness
|
|
1227
|
+
};
|
|
1228
|
+
current = await this.appendConvergent(operationId, requestDigest, event, state => {
|
|
1229
|
+
const persistedAction = state.actions[witness.actionId];
|
|
1230
|
+
const persisted = persistedAction === undefined
|
|
1231
|
+
? undefined
|
|
1232
|
+
: submissionWitnessForAction(state, persistedAction);
|
|
1233
|
+
return persisted !== undefined && canonicalJson(persisted) === canonicalJson(witness);
|
|
1234
|
+
});
|
|
1235
|
+
const persisted = submissionWitnessForAction(current.state, action);
|
|
1236
|
+
if (persisted === undefined || canonicalJson(persisted) !== canonicalJson(witness)) {
|
|
1237
|
+
throw new OperationServiceError("submission_witness_indeterminate", "Submission witness was not durably validated after persistence.");
|
|
1238
|
+
}
|
|
1239
|
+
return current;
|
|
1240
|
+
}
|
|
1241
|
+
submissionWitnessFromEstablishment(establishment, baselineSnapshotDigest, notBefore) {
|
|
1242
|
+
if (establishment.postSendDeltaDigest === undefined) {
|
|
1243
|
+
throw new OperationServiceError("target_establishment_delta_missing", "New-target establishment requires exact post-Send delta evidence.");
|
|
1244
|
+
}
|
|
1245
|
+
if (baselineSnapshotDigest === undefined) {
|
|
1246
|
+
throw new OperationServiceError("ownership_baseline_missing", "New-target establishment requires the durable pre-Send ownership baseline.");
|
|
1247
|
+
}
|
|
1248
|
+
const observedAt = notBefore !== undefined && Date.parse(establishment.observedAt) < Date.parse(notBefore)
|
|
1249
|
+
? notBefore
|
|
1250
|
+
: establishment.observedAt;
|
|
1251
|
+
return {
|
|
1252
|
+
schemaVersion: OPERATION_SUBMISSION_WITNESS_SCHEMA_VERSION,
|
|
1253
|
+
actionId: establishment.causalSendActionId,
|
|
1254
|
+
actionKind: "send",
|
|
1255
|
+
targetBindingDigest: establishment.targetBindingDigest,
|
|
1256
|
+
baselineSnapshotDigest,
|
|
1257
|
+
postSendDeltaDigest: establishment.postSendDeltaDigest,
|
|
1258
|
+
operationUserEvidenceDigest: establishment.userTurnEvidenceDigest,
|
|
1259
|
+
userTurnId: establishment.userTurnId,
|
|
1260
|
+
observedAt
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
submissionWitnessFromReceipt(receipt, actionId, baselineSnapshotDigest, notBefore) {
|
|
1264
|
+
return {
|
|
1265
|
+
schemaVersion: OPERATION_SUBMISSION_WITNESS_SCHEMA_VERSION,
|
|
1266
|
+
actionId,
|
|
1267
|
+
actionKind: "send",
|
|
1268
|
+
targetBindingDigest: receipt.targetBindingDigest,
|
|
1269
|
+
baselineSnapshotDigest,
|
|
1270
|
+
postSendDeltaDigest: receipt.postSendDeltaDigest,
|
|
1271
|
+
operationUserEvidenceDigest: receipt.userTurnEvidenceDigest,
|
|
1272
|
+
userTurnId: receipt.userTurnId,
|
|
1273
|
+
observedAt: receipt.observedAt ?? this.timestamp(notBefore)
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
async appendActionReceiptConvergent(operationId, requestDigest, actionId, outcome, evidenceDigest, blockerCode, observedAt) {
|
|
1277
|
+
const current = await this.journal.load(operationId, requestDigest);
|
|
1278
|
+
const action = current.state.actions[actionId];
|
|
1279
|
+
if (action === undefined)
|
|
1280
|
+
throw new OperationServiceError("operation_state_inconsistent", "Action receipt has no durable intent.");
|
|
1281
|
+
if (action.outcome !== undefined) {
|
|
1282
|
+
if (action.outcome !== outcome)
|
|
1283
|
+
throw new OperationServiceError("operation_state_corrupt", "Action receipt outcome conflicts with durable state.");
|
|
1284
|
+
if (evidenceDigest !== undefined && action.evidenceDigest !== evidenceDigest) {
|
|
1285
|
+
throw new OperationServiceError("operation_state_corrupt", "Action receipt evidence conflicts with durable state.");
|
|
1286
|
+
}
|
|
1287
|
+
if (blockerCode !== undefined && action.blockerCode !== blockerCode) {
|
|
1288
|
+
throw new OperationServiceError("operation_state_corrupt", "Action receipt blocker conflicts with durable state.");
|
|
1289
|
+
}
|
|
1290
|
+
return current;
|
|
1291
|
+
}
|
|
1292
|
+
if (outcome === "satisfied" && (evidenceDigest === undefined || !DIGEST_PATTERN.test(evidenceDigest))) {
|
|
1293
|
+
throw new OperationServiceError("invalid_action_receipt", "A satisfied action requires canonical evidence.");
|
|
1294
|
+
}
|
|
1295
|
+
const event = {
|
|
1296
|
+
type: "action_receipt",
|
|
1297
|
+
actionId,
|
|
1298
|
+
outcome,
|
|
1299
|
+
...(evidenceDigest === undefined ? {} : { evidenceDigest }),
|
|
1300
|
+
...(blockerCode === undefined ? {} : { blockerCode }),
|
|
1301
|
+
observedAt: observedAt ?? this.timestamp(action.intentAt)
|
|
1302
|
+
};
|
|
1303
|
+
return this.appendConvergent(operationId, requestDigest, event, state => state.actions[actionId]?.outcome === outcome);
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Append the complete redacted pre-Send ownership baseline. The callback is
|
|
1307
|
+
* invoked by the adapter after its final precondition read and before its
|
|
1308
|
+
* sole browser activation. Consequently a process crash can leave either a
|
|
1309
|
+
* durable baseline with no click, or a durable baseline plus a Send intent;
|
|
1310
|
+
* it can never require reconstructing authority from a post-Send page.
|
|
1311
|
+
*/
|
|
1312
|
+
async persistOwnershipBaseline(request) {
|
|
1313
|
+
const current = await this.journal.load(request.operationId, request.requestDigest);
|
|
1314
|
+
const action = current.state.actions[request.actionId];
|
|
1315
|
+
if (action === undefined
|
|
1316
|
+
|| (action.kind !== "send" && action.kind !== "work_steer")
|
|
1317
|
+
|| action.targetDigest !== request.targetBindingDigest
|
|
1318
|
+
|| action.requestDigest !== request.requestDigest
|
|
1319
|
+
|| action.outcome !== undefined) {
|
|
1320
|
+
throw new OperationServiceError("ownership_baseline_action_invalid", "The pre-Send baseline is not adjacent to an unsettled causal action intent.");
|
|
1321
|
+
}
|
|
1322
|
+
const baseline = {
|
|
1323
|
+
schemaVersion: OPERATION_OWNERSHIP_BASELINE_SCHEMA_VERSION,
|
|
1324
|
+
operationId: request.operationId,
|
|
1325
|
+
requestDigest: request.requestDigest,
|
|
1326
|
+
targetBindingDigest: request.targetBindingDigest,
|
|
1327
|
+
actionId: request.actionId,
|
|
1328
|
+
baseline: request.baseline,
|
|
1329
|
+
// Derive the wrapper timestamp from the immutable loaded prefix so
|
|
1330
|
+
// concurrent identical writers produce byte-identical events and
|
|
1331
|
+
// converge instead of conflicting on wall-clock jitter.
|
|
1332
|
+
observedAt: current.state.updatedAt
|
|
1333
|
+
};
|
|
1334
|
+
try {
|
|
1335
|
+
assertOwnershipBaselineShape(baseline);
|
|
1336
|
+
}
|
|
1337
|
+
catch {
|
|
1338
|
+
throw new OperationServiceError("ownership_baseline_invalid", "The provider returned an invalid or unbounded pre-Send ownership baseline.");
|
|
1339
|
+
}
|
|
1340
|
+
const existing = current.state.ownershipBaseline;
|
|
1341
|
+
if (existing !== undefined) {
|
|
1342
|
+
if (canonicalJson(existing) !== canonicalJson(baseline)) {
|
|
1343
|
+
throw new OperationServiceError("ownership_baseline_conflict", "A different immutable pre-Send ownership baseline is already durable.");
|
|
1344
|
+
}
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
const event = {
|
|
1348
|
+
type: "ownership_baseline",
|
|
1349
|
+
baseline
|
|
1350
|
+
};
|
|
1351
|
+
const loaded = await this.appendConvergent(request.operationId, request.requestDigest, event, state => state.ownershipBaseline !== undefined && canonicalJson(state.ownershipBaseline) === canonicalJson(baseline));
|
|
1352
|
+
if (loaded.state.ownershipBaseline === undefined || canonicalJson(loaded.state.ownershipBaseline) !== canonicalJson(baseline)) {
|
|
1353
|
+
throw new OperationServiceError("ownership_baseline_indeterminate", "The pre-Send ownership baseline was not durably validated after persistence.");
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
async appendPhaseConvergent(operationId, requestDigest, phase, causeActionId, evidenceDigest) {
|
|
1357
|
+
for (let attempt = 0; attempt < this.maxCasRetries; attempt += 1) {
|
|
1358
|
+
const loaded = await this.journal.load(operationId, requestDigest);
|
|
1359
|
+
if (phaseReached(loaded.state.phase, phase))
|
|
1360
|
+
return loaded;
|
|
1361
|
+
if (loaded.state.phase === "completed")
|
|
1362
|
+
return loaded;
|
|
1363
|
+
const event = {
|
|
1364
|
+
type: "phase_changed",
|
|
1365
|
+
from: loaded.state.phase,
|
|
1366
|
+
to: phase,
|
|
1367
|
+
mutationBoundary: loaded.state.mutationBoundary,
|
|
1368
|
+
...(causeActionId === undefined ? {} : { causeActionId }),
|
|
1369
|
+
...(evidenceDigest === undefined ? {} : { evidenceDigest }),
|
|
1370
|
+
observedAt: this.timestamp(loaded.state.updatedAt)
|
|
1371
|
+
};
|
|
1372
|
+
try {
|
|
1373
|
+
return await this.journal.append(operationId, loaded.state.revision, event);
|
|
1374
|
+
}
|
|
1375
|
+
catch (error) {
|
|
1376
|
+
if (await this.eventEffectExists(operationId, requestDigest, state => phaseReached(state.phase, phase))) {
|
|
1377
|
+
return await this.journal.load(operationId, requestDigest);
|
|
1378
|
+
}
|
|
1379
|
+
if (isRevisionConflict(error))
|
|
1380
|
+
continue;
|
|
1381
|
+
throw this.serviceError(error, "journal_unavailable");
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
throw new OperationServiceError("journal_conflict", "Concurrent operation writers did not converge within the retry bound.");
|
|
1385
|
+
}
|
|
1386
|
+
async appendConvergent(operationId, requestDigest, event, effect) {
|
|
1387
|
+
for (let attempt = 0; attempt < this.maxCasRetries; attempt += 1) {
|
|
1388
|
+
const loaded = await this.journal.load(operationId, requestDigest);
|
|
1389
|
+
if (effect(loaded.state))
|
|
1390
|
+
return loaded;
|
|
1391
|
+
try {
|
|
1392
|
+
return await this.journal.append(operationId, loaded.state.revision, event);
|
|
1393
|
+
}
|
|
1394
|
+
catch (error) {
|
|
1395
|
+
try {
|
|
1396
|
+
const observed = await this.journal.load(operationId, requestDigest);
|
|
1397
|
+
if (effect(observed.state))
|
|
1398
|
+
return observed;
|
|
1399
|
+
}
|
|
1400
|
+
catch {
|
|
1401
|
+
// Preserve the original write error. A committed-prefix recovery
|
|
1402
|
+
// attempt must never turn corruption into a successful mutation.
|
|
1403
|
+
}
|
|
1404
|
+
if (isRevisionConflict(error))
|
|
1405
|
+
continue;
|
|
1406
|
+
throw this.serviceError(error, "journal_unavailable");
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
throw new OperationServiceError("journal_conflict", "Concurrent operation writers did not converge within the retry bound.");
|
|
1410
|
+
}
|
|
1411
|
+
async eventEffectExists(operationId, requestDigest, effect) {
|
|
1412
|
+
const loaded = await this.journal.load(operationId, requestDigest);
|
|
1413
|
+
return effect(loaded.state);
|
|
1414
|
+
}
|
|
1415
|
+
async persistProgress(request) {
|
|
1416
|
+
assertOperationStateShape(request.durable.state);
|
|
1417
|
+
assertDigest(request.evidenceDigest, "progress evidenceDigest");
|
|
1418
|
+
const operationId = request.durable.state.operationId;
|
|
1419
|
+
const requestDigest = request.durable.state.requestDigest;
|
|
1420
|
+
let current = await this.journal.load(operationId, requestDigest);
|
|
1421
|
+
const submit = originalSendAction(current.state);
|
|
1422
|
+
if (submit === undefined) {
|
|
1423
|
+
throw new OperationServiceError("operation_state_inconsistent", "Owned progress has no durable original Send intent.");
|
|
1424
|
+
}
|
|
1425
|
+
if (current.state.submissionWitness === undefined) {
|
|
1426
|
+
throw new OperationServiceError("submission_witness_missing", "Owned progress cannot advance without a durable submission witness.");
|
|
1427
|
+
}
|
|
1428
|
+
if (current.state.ownershipBaseline === undefined) {
|
|
1429
|
+
throw new OperationServiceError("ownership_baseline_missing", "Owned progress cannot advance without a durable pre-Send ownership baseline.");
|
|
1430
|
+
}
|
|
1431
|
+
const activeOwnership = latestSubmissionOwnership(current.state);
|
|
1432
|
+
assertCollectorOwnership(request.durable, activeOwnership);
|
|
1433
|
+
if (current.state.phase === "completed" || phaseReached(current.state.phase, request.phase)) {
|
|
1434
|
+
return { ...request.durable, state: current.state };
|
|
1435
|
+
}
|
|
1436
|
+
if (current.state.phase === "prepared" || current.state.phase === "handoff_pending" || current.state.phase === "capturing") {
|
|
1437
|
+
throw new OperationServiceError("operation_state_inconsistent", `Owned progress cannot advance from ${current.state.phase}.`);
|
|
1438
|
+
}
|
|
1439
|
+
// Repair both crash boundaries around Send before advancing observable
|
|
1440
|
+
// generation state. Every append is convergent and browser-free.
|
|
1441
|
+
if (current.state.phase === "ready") {
|
|
1442
|
+
if (submit.outcome === undefined) {
|
|
1443
|
+
current = await this.appendPhaseConvergent(operationId, requestDigest, "send_pending", submit.actionId);
|
|
1444
|
+
}
|
|
1445
|
+
else {
|
|
1446
|
+
current = await this.appendPhaseConvergent(operationId, requestDigest, "uncertain", submit.actionId, request.evidenceDigest);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
if (submit.outcome === undefined) {
|
|
1450
|
+
current = await this.appendActionReceiptConvergent(operationId, requestDigest, submit.actionId, "satisfied", request.evidenceDigest);
|
|
1451
|
+
}
|
|
1452
|
+
else if (submit.outcome !== "satisfied") {
|
|
1453
|
+
throw new OperationServiceError("operation_state_corrupt", "Owned progress conflicts with the durable Send outcome.");
|
|
1454
|
+
}
|
|
1455
|
+
current = await this.journal.load(operationId, requestDigest);
|
|
1456
|
+
if (current.state.phase === "send_pending" || current.state.phase === "uncertain") {
|
|
1457
|
+
current = await this.appendPhaseConvergent(operationId, requestDigest, "submitted", submit.actionId, request.evidenceDigest);
|
|
1458
|
+
}
|
|
1459
|
+
if (request.phase === "generating" && current.state.phase === "submitted") {
|
|
1460
|
+
current = await this.appendPhaseConvergent(operationId, requestDigest, "generating", submit.actionId, request.evidenceDigest);
|
|
1461
|
+
}
|
|
1462
|
+
const fresh = await this.journal.load(operationId, requestDigest);
|
|
1463
|
+
if (!phaseReached(fresh.state.phase, request.phase)) {
|
|
1464
|
+
throw new OperationServiceError("operation_state_inconsistent", "Durable progress did not reach the proven ownership phase.");
|
|
1465
|
+
}
|
|
1466
|
+
return { ...request.durable, state: fresh.state };
|
|
1467
|
+
}
|
|
1468
|
+
async persistTerminal(request, artifactAdapter) {
|
|
1469
|
+
assertOperationStateShape(request.durable.state);
|
|
1470
|
+
const operationId = request.durable.state.operationId;
|
|
1471
|
+
const requestDigest = request.durable.state.requestDigest;
|
|
1472
|
+
validateReceiptIdentity(request.receipt, operationId, requestDigest, request.durable.binding.targetBindingDigest);
|
|
1473
|
+
let current = await this.journal.load(operationId, requestDigest);
|
|
1474
|
+
if (current.state.receipt !== undefined) {
|
|
1475
|
+
const transferCapture = current.state.capturePolicy?.artifacts === "transfer";
|
|
1476
|
+
if (!sameReceipt(current.state.receipt, request.receipt)
|
|
1477
|
+
&& !(transferCapture && sameTerminalReceiptIdentity(current.state.receipt, request.receipt))) {
|
|
1478
|
+
throw new OperationServiceError("operation_receipt_indeterminate", "A different terminal receipt is already durable.");
|
|
1479
|
+
}
|
|
1480
|
+
return { ...request.durable, state: current.state };
|
|
1481
|
+
}
|
|
1482
|
+
const submit = originalSendAction(current.state);
|
|
1483
|
+
if (submit === undefined)
|
|
1484
|
+
throw new OperationServiceError("operation_state_inconsistent", "Terminal collection has no durable submission intent.");
|
|
1485
|
+
if (current.state.submissionWitness === undefined) {
|
|
1486
|
+
throw new OperationServiceError("submission_witness_missing", "Terminal collection cannot advance without a durable submission witness.");
|
|
1487
|
+
}
|
|
1488
|
+
if (current.state.ownershipBaseline === undefined) {
|
|
1489
|
+
throw new OperationServiceError("ownership_baseline_missing", "Terminal collection cannot advance without a durable pre-Send ownership baseline.");
|
|
1490
|
+
}
|
|
1491
|
+
const activeOwnership = latestSubmissionOwnership(current.state);
|
|
1492
|
+
assertCollectorOwnership(request.durable, activeOwnership);
|
|
1493
|
+
current = await this.advanceToCapturing(current, submit, request.receipt);
|
|
1494
|
+
// The collector owns the exact terminal observation. Transfer is the one
|
|
1495
|
+
// explicitly requested local effect layered on top of that observation.
|
|
1496
|
+
// It runs only after the operation has durably entered `capturing`, and
|
|
1497
|
+
// every browser/provider capability remains inside the adapter closure.
|
|
1498
|
+
const terminalReceipt = current.state.capturePolicy?.artifacts === "transfer"
|
|
1499
|
+
? await this.persistTerminalArtifactTransfers(current, request.receipt, artifactAdapter, request.signal, request.deadlineAt)
|
|
1500
|
+
: request.receipt;
|
|
1501
|
+
const observedAt = this.timestamp(request.receipt.completedAt);
|
|
1502
|
+
const receiptEvent = {
|
|
1503
|
+
type: "receipt_completed",
|
|
1504
|
+
receipt: terminalReceipt,
|
|
1505
|
+
observedAt
|
|
1506
|
+
};
|
|
1507
|
+
current = await this.appendConvergent(operationId, requestDigest, receiptEvent, state => state.receipt !== undefined && sameReceipt(state.receipt, terminalReceipt));
|
|
1508
|
+
const fresh = await this.journal.load(operationId, requestDigest);
|
|
1509
|
+
if (fresh.state.phase !== "completed" || fresh.state.receipt === undefined || !sameReceipt(fresh.state.receipt, terminalReceipt)) {
|
|
1510
|
+
throw new OperationServiceError("operation_receipt_indeterminate", "Terminal receipt was not durably validated after persistence.");
|
|
1511
|
+
}
|
|
1512
|
+
return { ...request.durable, state: fresh.state };
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Transfer each exact terminal artifact and project only durable transfer
|
|
1516
|
+
* facts into the operation receipt. No provider result is trusted until its
|
|
1517
|
+
* journal callbacks have produced an authenticated artifact-transfer
|
|
1518
|
+
* receipt. A missing adapter, a prior intent without a receipt, or an
|
|
1519
|
+
* adapter protocol failure closes the one-shot obligation as partial/blocked
|
|
1520
|
+
* without re-opening the source.
|
|
1521
|
+
*/
|
|
1522
|
+
async persistTerminalArtifactTransfers(initial, observed, artifactAdapter, signal, deadlineAt) {
|
|
1523
|
+
if (observed.artifacts.length === 0)
|
|
1524
|
+
return observed;
|
|
1525
|
+
let current = await this.journal.load(initial.state.operationId, initial.state.requestDigest);
|
|
1526
|
+
const projected = [];
|
|
1527
|
+
for (const artifact of observed.artifacts) {
|
|
1528
|
+
const transferReceipt = await this.persistTerminalArtifactTransfer(current, observed, artifact, artifactAdapter, signal, deadlineAt);
|
|
1529
|
+
projected.push(projectArtifactTransferReceipt(artifact, transferReceipt));
|
|
1530
|
+
current = await this.journal.load(initial.state.operationId, initial.state.requestDigest);
|
|
1531
|
+
}
|
|
1532
|
+
const terminalReceipt = {
|
|
1533
|
+
...observed,
|
|
1534
|
+
artifacts: projected
|
|
1535
|
+
};
|
|
1536
|
+
// This is intentionally a narrow invariant check rather than a call into
|
|
1537
|
+
// collector internals: terminal persistence may enrich artifacts but may
|
|
1538
|
+
// not rewrite the owned turn, response, or artifact identity.
|
|
1539
|
+
assertTerminalArtifactProjection(terminalReceipt, observed);
|
|
1540
|
+
return terminalReceipt;
|
|
1541
|
+
}
|
|
1542
|
+
async persistTerminalArtifactTransfer(current, observed, artifact, artifactAdapter, signal, deadlineAt) {
|
|
1543
|
+
const operationId = current.state.operationId;
|
|
1544
|
+
const requestDigest = current.state.requestDigest;
|
|
1545
|
+
const targetBindingDigest = observed.targetBindingDigest;
|
|
1546
|
+
const existing = findArtifactTransfer(current.state, observed.assistantTurnId, artifact);
|
|
1547
|
+
const transferActionId = await this.artifactTransferActionId(operationId, requestDigest, observed.assistantTurnId, artifact);
|
|
1548
|
+
const lookupIdentity = {
|
|
1549
|
+
operationId,
|
|
1550
|
+
requestDigest,
|
|
1551
|
+
targetBindingDigest,
|
|
1552
|
+
assistantTurnId: observed.assistantTurnId,
|
|
1553
|
+
sourceIdentityDigest: artifact.sourceIdentityDigest,
|
|
1554
|
+
kind: artifact.kind,
|
|
1555
|
+
ordinal: artifact.ordinal,
|
|
1556
|
+
transferActionId
|
|
1557
|
+
};
|
|
1558
|
+
const journal = this.artifactTransferJournal(lookupIdentity);
|
|
1559
|
+
const flightKey = artifactTransferFlightKey(lookupIdentity);
|
|
1560
|
+
// If this service already owns the same transfer call, wait for that
|
|
1561
|
+
// exact invocation before interpreting an intent-only prefix. Otherwise a
|
|
1562
|
+
// concurrent collector could prematurely close the other's in-flight
|
|
1563
|
+
// source as partial and make the first receipt append fail.
|
|
1564
|
+
const existingFlight = this.artifactTransfersInFlight.get(flightKey);
|
|
1565
|
+
if (existingFlight !== undefined) {
|
|
1566
|
+
await existingFlight;
|
|
1567
|
+
current = await this.journal.load(operationId, requestDigest);
|
|
1568
|
+
const converged = findArtifactTransfer(current.state, observed.assistantTurnId, artifact);
|
|
1569
|
+
if (converged?.receipt !== undefined)
|
|
1570
|
+
return converged.receipt;
|
|
1571
|
+
if (converged?.intent !== undefined) {
|
|
1572
|
+
return await this.closeArtifactTransferWithoutSource(current, converged.intent, "artifact_transfer_partial");
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
if (existing?.receipt !== undefined)
|
|
1576
|
+
return existing.receipt;
|
|
1577
|
+
// A durable intent is already the source boundary. The transfer primitive
|
|
1578
|
+
// must never retry the provider after a restart or an acknowledgement
|
|
1579
|
+
// fault; close it with a path-free partial receipt instead.
|
|
1580
|
+
if (existing?.intent !== undefined) {
|
|
1581
|
+
return await this.closeArtifactTransferWithoutSource(current, existing.intent, "artifact_transfer_partial");
|
|
1582
|
+
}
|
|
1583
|
+
let result;
|
|
1584
|
+
if (artifactAdapter !== undefined) {
|
|
1585
|
+
const transferRequest = Object.freeze({
|
|
1586
|
+
...lookupIdentity,
|
|
1587
|
+
...(artifact.mimeType === undefined ? {} : { mimeTypeHint: artifact.mimeType }),
|
|
1588
|
+
signal,
|
|
1589
|
+
deadlineAt,
|
|
1590
|
+
journal
|
|
1591
|
+
});
|
|
1592
|
+
const flight = this.invokeArtifactAdapter(artifactAdapter, transferRequest);
|
|
1593
|
+
this.artifactTransfersInFlight.set(flightKey, flight);
|
|
1594
|
+
try {
|
|
1595
|
+
result = await flight;
|
|
1596
|
+
}
|
|
1597
|
+
finally {
|
|
1598
|
+
if (this.artifactTransfersInFlight.get(flightKey) === flight) {
|
|
1599
|
+
this.artifactTransfersInFlight.delete(flightKey);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
// Always authenticate the adapter's durable side effects after it returns.
|
|
1604
|
+
// The result object is useful only as a diagnostic signal; its receipt is
|
|
1605
|
+
// not an authority until it is present in the operation journal.
|
|
1606
|
+
current = await this.journal.load(operationId, requestDigest);
|
|
1607
|
+
const after = findArtifactTransfer(current.state, observed.assistantTurnId, artifact);
|
|
1608
|
+
if (after?.receipt !== undefined)
|
|
1609
|
+
return after.receipt;
|
|
1610
|
+
if (after?.intent !== undefined) {
|
|
1611
|
+
return await this.closeArtifactTransferWithoutSource(current, after.intent, artifactAdapter === undefined || result === undefined
|
|
1612
|
+
? "artifact_transfer_unavailable"
|
|
1613
|
+
: "artifact_transfer_protocol_violation");
|
|
1614
|
+
}
|
|
1615
|
+
// The adapter may have thrown before it could persist its destination
|
|
1616
|
+
// identity. Establish a deterministic, path-free durable boundary and
|
|
1617
|
+
// close it as blocked. This also handles a missing restart adapter.
|
|
1618
|
+
const fallbackIntent = await this.makeUnavailableArtifactTransferIntent(current, observed.assistantTurnId, artifact, transferActionId);
|
|
1619
|
+
await this.persistArtifactTransferIntent(fallbackIntent);
|
|
1620
|
+
const withIntent = await this.journal.load(operationId, requestDigest);
|
|
1621
|
+
return await this.closeArtifactTransferWithoutSource(withIntent, fallbackIntent, artifactAdapter === undefined ? "artifact_transfer_unavailable" : "artifact_transfer_protocol_violation", "blocked");
|
|
1622
|
+
}
|
|
1623
|
+
async invokeArtifactAdapter(adapter, request) {
|
|
1624
|
+
try {
|
|
1625
|
+
const result = await adapter.transfer(request);
|
|
1626
|
+
if (result === null || typeof result !== "object") {
|
|
1627
|
+
throw new OperationServiceError("artifact_transfer_protocol_violation", "Artifact transfer adapter returned an invalid result.");
|
|
1628
|
+
}
|
|
1629
|
+
return result;
|
|
1630
|
+
}
|
|
1631
|
+
catch {
|
|
1632
|
+
// Do not carry adapter/provider diagnostics over the service boundary.
|
|
1633
|
+
// The caller reconciles the journal and closes the obligation safely.
|
|
1634
|
+
return {
|
|
1635
|
+
schemaVersion: "chatgpt.browser_control.artifact_transfer.v1",
|
|
1636
|
+
outcome: "uncertain",
|
|
1637
|
+
replayed: false,
|
|
1638
|
+
intentPersistence: "indeterminate",
|
|
1639
|
+
receiptPersistence: "indeterminate",
|
|
1640
|
+
blockerCode: "artifact_transfer_exception"
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
async artifactTransferActionId(operationId, requestDigest, assistantTurnId, artifact) {
|
|
1645
|
+
const evidence = await this.journal.evidenceDigest("artifact-transfer-action", {
|
|
1646
|
+
operationId,
|
|
1647
|
+
requestDigest,
|
|
1648
|
+
assistantTurnId,
|
|
1649
|
+
sourceIdentityDigest: artifact.sourceIdentityDigest,
|
|
1650
|
+
kind: artifact.kind,
|
|
1651
|
+
ordinal: artifact.ordinal
|
|
1652
|
+
});
|
|
1653
|
+
const hex = evidence.slice("hmac-sha256:".length);
|
|
1654
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
1655
|
+
}
|
|
1656
|
+
async makeUnavailableArtifactTransferIntent(current, assistantTurnId, artifact, transferActionId) {
|
|
1657
|
+
const destinationIdentityDigest = await this.journal.evidenceDigest("artifact-destination", {
|
|
1658
|
+
schemaVersion: OPERATION_ARTIFACT_TRANSFER_INTENT_SCHEMA_VERSION,
|
|
1659
|
+
operationId: current.state.operationId,
|
|
1660
|
+
requestDigest: current.state.requestDigest,
|
|
1661
|
+
targetBindingDigest: await this.targetBindingDigest(current.state),
|
|
1662
|
+
assistantTurnId,
|
|
1663
|
+
sourceIdentityDigest: artifact.sourceIdentityDigest,
|
|
1664
|
+
kind: artifact.kind,
|
|
1665
|
+
ordinal: artifact.ordinal,
|
|
1666
|
+
transferActionId,
|
|
1667
|
+
destination: "unavailable"
|
|
1668
|
+
});
|
|
1669
|
+
return {
|
|
1670
|
+
schemaVersion: OPERATION_ARTIFACT_TRANSFER_INTENT_SCHEMA_VERSION,
|
|
1671
|
+
operationId: current.state.operationId,
|
|
1672
|
+
requestDigest: current.state.requestDigest,
|
|
1673
|
+
targetBindingDigest: await this.targetBindingDigest(current.state),
|
|
1674
|
+
assistantTurnId,
|
|
1675
|
+
sourceIdentityDigest: artifact.sourceIdentityDigest,
|
|
1676
|
+
kind: artifact.kind,
|
|
1677
|
+
ordinal: artifact.ordinal,
|
|
1678
|
+
transferActionId,
|
|
1679
|
+
destinationIdentityDigest,
|
|
1680
|
+
actionKind: "local_output_commit",
|
|
1681
|
+
repeatPolicy: "reconcile_local_effect",
|
|
1682
|
+
intentAt: current.state.updatedAt
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
async closeArtifactTransferWithoutSource(current, intent, blockerCode, status = "partial") {
|
|
1686
|
+
const receipt = {
|
|
1687
|
+
schemaVersion: OPERATION_ARTIFACT_TRANSFER_RECEIPT_SCHEMA_VERSION,
|
|
1688
|
+
operationId: intent.operationId,
|
|
1689
|
+
requestDigest: intent.requestDigest,
|
|
1690
|
+
targetBindingDigest: intent.targetBindingDigest,
|
|
1691
|
+
assistantTurnId: intent.assistantTurnId,
|
|
1692
|
+
sourceIdentityDigest: intent.sourceIdentityDigest,
|
|
1693
|
+
kind: intent.kind,
|
|
1694
|
+
ordinal: intent.ordinal,
|
|
1695
|
+
transferActionId: intent.transferActionId,
|
|
1696
|
+
destinationIdentityDigest: intent.destinationIdentityDigest,
|
|
1697
|
+
status,
|
|
1698
|
+
blockerCode,
|
|
1699
|
+
observedAt: this.timestamp(intent.intentAt)
|
|
1700
|
+
};
|
|
1701
|
+
await this.persistArtifactTransferReceipt(receipt);
|
|
1702
|
+
const fresh = await this.journal.load(current.state.operationId, current.state.requestDigest);
|
|
1703
|
+
const persisted = findArtifactTransfer(fresh.state, intent.assistantTurnId, {
|
|
1704
|
+
operationId: intent.operationId,
|
|
1705
|
+
assistantTurnId: intent.assistantTurnId,
|
|
1706
|
+
sourceIdentityDigest: intent.sourceIdentityDigest,
|
|
1707
|
+
kind: intent.kind,
|
|
1708
|
+
ordinal: intent.ordinal
|
|
1709
|
+
});
|
|
1710
|
+
if (persisted?.receipt === undefined) {
|
|
1711
|
+
throw new OperationServiceError("operation_receipt_indeterminate", "Artifact transfer receipt was not durably validated.");
|
|
1712
|
+
}
|
|
1713
|
+
return persisted.receipt;
|
|
1714
|
+
}
|
|
1715
|
+
artifactTransferJournal(expected) {
|
|
1716
|
+
// The adapter receives a service-owned port, not the whole operation
|
|
1717
|
+
// journal. Bind every callback to this exact artifact tuple so an adapter
|
|
1718
|
+
// cannot accidentally (or maliciously) append a sibling transfer for a
|
|
1719
|
+
// different operation, turn, source, kind, ordinal, or action ID. The
|
|
1720
|
+
// destination identity is intentionally left to the adapter: it is
|
|
1721
|
+
// derived from the request-local destination and is checked by the
|
|
1722
|
+
// durable state machine when the callback is appended.
|
|
1723
|
+
const assertExpectedIdentity = (actual) => {
|
|
1724
|
+
if (!sameArtifactTransferIdentity(expected, actual)) {
|
|
1725
|
+
throw new OperationServiceError("operation_request_mismatch", "Artifact transfer callback identity does not match the exact terminal artifact.");
|
|
1726
|
+
}
|
|
1727
|
+
};
|
|
1728
|
+
return Object.freeze({
|
|
1729
|
+
readActionState: (lookup) => {
|
|
1730
|
+
assertExpectedIdentity(lookup);
|
|
1731
|
+
return this.readArtifactTransferState(lookup);
|
|
1732
|
+
},
|
|
1733
|
+
persistIntent: (intent) => {
|
|
1734
|
+
assertExpectedIdentity(intent);
|
|
1735
|
+
return this.persistArtifactTransferIntent(intent);
|
|
1736
|
+
},
|
|
1737
|
+
persistReceipt: (receipt) => {
|
|
1738
|
+
assertExpectedIdentity(receipt);
|
|
1739
|
+
return this.persistArtifactTransferReceipt(receipt);
|
|
1740
|
+
}
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
async readArtifactTransferState(lookup) {
|
|
1744
|
+
const current = await this.journal.load(lookup.operationId, lookup.requestDigest);
|
|
1745
|
+
if (current.state.target === undefined || await this.targetBindingDigest(current.state) !== lookup.targetBindingDigest) {
|
|
1746
|
+
throw new OperationServiceError("operation_request_mismatch", "Artifact transfer target identity does not match durable state.");
|
|
1747
|
+
}
|
|
1748
|
+
const transfer = current.state.artifactTransfers?.[lookup.transferActionId];
|
|
1749
|
+
if (transfer === undefined)
|
|
1750
|
+
return undefined;
|
|
1751
|
+
if (transfer.intent === undefined) {
|
|
1752
|
+
throw new OperationServiceError("operation_state_corrupt", "Artifact transfer state is missing its durable intent.");
|
|
1753
|
+
}
|
|
1754
|
+
if (!artifactTransferIdentityMatches(transfer.intent, lookup)) {
|
|
1755
|
+
throw new OperationServiceError("operation_request_mismatch", "Artifact transfer action identity does not match durable state.");
|
|
1756
|
+
}
|
|
1757
|
+
return transfer;
|
|
1758
|
+
}
|
|
1759
|
+
async persistArtifactTransferIntent(intent) {
|
|
1760
|
+
const event = {
|
|
1761
|
+
type: "artifact_transfer_intent",
|
|
1762
|
+
intent
|
|
1763
|
+
};
|
|
1764
|
+
await this.appendConvergent(intent.operationId, intent.requestDigest, event, state => {
|
|
1765
|
+
const transfer = state.artifactTransfers?.[intent.transferActionId];
|
|
1766
|
+
return transfer?.intent !== undefined && canonicalJson(transfer.intent) === canonicalJson(intent);
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
async persistArtifactTransferReceipt(receipt) {
|
|
1770
|
+
const event = {
|
|
1771
|
+
type: "artifact_transfer_receipt",
|
|
1772
|
+
receipt
|
|
1773
|
+
};
|
|
1774
|
+
await this.appendConvergent(receipt.operationId, receipt.requestDigest, event, state => state.artifactTransfers?.[receipt.transferActionId]?.receipt !== undefined
|
|
1775
|
+
&& canonicalJson(state.artifactTransfers[receipt.transferActionId].receipt) === canonicalJson(receipt));
|
|
1776
|
+
}
|
|
1777
|
+
async advanceToCapturing(initial, submit, receipt) {
|
|
1778
|
+
const operationId = initial.state.operationId;
|
|
1779
|
+
const requestDigest = initial.state.requestDigest;
|
|
1780
|
+
let current = initial;
|
|
1781
|
+
if (current.state.phase === "completed")
|
|
1782
|
+
return current;
|
|
1783
|
+
if (current.state.phase === "prepared" || current.state.phase === "handoff_pending") {
|
|
1784
|
+
throw new OperationServiceError("operation_state_inconsistent", "Terminal ownership cannot precede a durable Send intent phase.");
|
|
1785
|
+
}
|
|
1786
|
+
// Crash gap: the Send intent append can be durable while the adjacent
|
|
1787
|
+
// send_pending phase append is absent. Preserve legal causality before
|
|
1788
|
+
// recording the observed user-turn receipt.
|
|
1789
|
+
if (current.state.phase === "ready") {
|
|
1790
|
+
if (submit.outcome === undefined) {
|
|
1791
|
+
current = await this.appendPhaseConvergent(operationId, requestDigest, "send_pending", submit.actionId);
|
|
1792
|
+
}
|
|
1793
|
+
else {
|
|
1794
|
+
// A satisfied receipt at ready is a valid committed prefix but cannot
|
|
1795
|
+
// cross ready -> submitted directly. Quarantine, then recover through
|
|
1796
|
+
// the state machine's evidence-gated uncertain edge.
|
|
1797
|
+
current = await this.appendPhaseConvergent(operationId, requestDigest, "uncertain", submit.actionId, receipt.userTurnEvidenceDigest);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
if (submit.outcome === undefined) {
|
|
1801
|
+
current = await this.appendActionReceiptConvergent(operationId, requestDigest, submit.actionId, "satisfied", receipt.userTurnEvidenceDigest);
|
|
1802
|
+
}
|
|
1803
|
+
else if (submit.outcome !== "satisfied") {
|
|
1804
|
+
throw new OperationServiceError("operation_state_corrupt", "Terminal ownership conflicts with the durable Send outcome.");
|
|
1805
|
+
}
|
|
1806
|
+
current = await this.journal.load(operationId, requestDigest);
|
|
1807
|
+
if (current.state.phase === "send_pending" || current.state.phase === "uncertain") {
|
|
1808
|
+
current = await this.appendPhaseConvergent(operationId, requestDigest, "submitted", submit.actionId, receipt.userTurnEvidenceDigest);
|
|
1809
|
+
}
|
|
1810
|
+
if (current.state.phase === "submitted" || current.state.phase === "generating" || current.state.phase === "uncertain") {
|
|
1811
|
+
current = await this.appendPhaseConvergent(operationId, requestDigest, "capturing", submit.actionId, receipt.ownershipEvidenceDigest);
|
|
1812
|
+
}
|
|
1813
|
+
if (current.state.phase !== "capturing" && current.state.phase !== "completed") {
|
|
1814
|
+
throw new OperationServiceError("operation_state_inconsistent", `Terminal receipt cannot advance from ${current.state.phase}.`);
|
|
1815
|
+
}
|
|
1816
|
+
return current;
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Rebuild the prompt-free Work-steer prepared record from authenticated
|
|
1820
|
+
* journal state. The journal stores the action-prepared action/baseline;
|
|
1821
|
+
* assistant branch identity is derived from that baseline and the caller's
|
|
1822
|
+
* exact control request, then the keyed prepared digest is recomputed.
|
|
1823
|
+
*/
|
|
1824
|
+
async reconstructSteerIntent(state, controlActionId, parentRequestDigest, parentTargetBindingDigest, requestDigest, expectedAssistantTurnId) {
|
|
1825
|
+
const action = state.actions[controlActionId];
|
|
1826
|
+
if (action === undefined
|
|
1827
|
+
|| action.kind !== "work_steer"
|
|
1828
|
+
|| action.requestDigest !== requestDigest
|
|
1829
|
+
|| action.targetDigest !== parentTargetBindingDigest) {
|
|
1830
|
+
throw new OperationServiceError("operation_state_corrupt", "Durable Work-steer action identity is invalid.");
|
|
1831
|
+
}
|
|
1832
|
+
const durableBaseline = state.ownershipBaselines?.[controlActionId];
|
|
1833
|
+
if (durableBaseline === undefined
|
|
1834
|
+
|| durableBaseline.operationId !== state.operationId
|
|
1835
|
+
|| durableBaseline.requestDigest !== parentRequestDigest
|
|
1836
|
+
|| durableBaseline.targetBindingDigest !== parentTargetBindingDigest
|
|
1837
|
+
|| durableBaseline.actionId !== controlActionId) {
|
|
1838
|
+
throw new OperationServiceError("operation_state_corrupt", "Durable Work-steer action is missing its complete per-action baseline.");
|
|
1839
|
+
}
|
|
1840
|
+
const assistants = durableBaseline.baseline.assistantTurns.filter(assistant => assistant.stableId === expectedAssistantTurnId);
|
|
1841
|
+
if (assistants.length !== 1) {
|
|
1842
|
+
throw new OperationServiceError("operation_state_corrupt", "Durable Work-steer baseline does not identify exactly one expected assistant turn.");
|
|
1843
|
+
}
|
|
1844
|
+
const assistant = assistants[0];
|
|
1845
|
+
if (assistant.branchStableId === undefined || assistant.parentStableId === undefined) {
|
|
1846
|
+
throw new OperationServiceError("operation_state_corrupt", "Durable Work-steer baseline is missing assistant branch ancestry.");
|
|
1847
|
+
}
|
|
1848
|
+
let material;
|
|
1849
|
+
try {
|
|
1850
|
+
material = controlSteerPreparedDigestMaterial({
|
|
1851
|
+
parentOperationId: state.operationId,
|
|
1852
|
+
parentRequestDigest,
|
|
1853
|
+
parentTargetBindingDigest,
|
|
1854
|
+
controlActionId,
|
|
1855
|
+
expectedAssistantTurnId,
|
|
1856
|
+
assistantBranchId: assistant.branchStableId,
|
|
1857
|
+
assistantParentTurnId: assistant.parentStableId,
|
|
1858
|
+
baselineSnapshotDigest: durableBaseline.baseline.snapshotDigest,
|
|
1859
|
+
baseline: durableBaseline.baseline
|
|
1860
|
+
});
|
|
1861
|
+
}
|
|
1862
|
+
catch {
|
|
1863
|
+
throw new OperationServiceError("operation_state_corrupt", "Durable Work-steer prepared material is invalid.");
|
|
1864
|
+
}
|
|
1865
|
+
const preparedDigest = await this.journal.evidenceDigest("work-steer-prepared", material);
|
|
1866
|
+
return Object.freeze({
|
|
1867
|
+
schemaVersion: CONTROL_COORDINATOR_SCHEMA_VERSION,
|
|
1868
|
+
parentOperationId: state.operationId,
|
|
1869
|
+
parentRequestDigest,
|
|
1870
|
+
parentTargetBindingDigest,
|
|
1871
|
+
controlActionId,
|
|
1872
|
+
action: "steer",
|
|
1873
|
+
requestDigest,
|
|
1874
|
+
expectedAssistantTurnId,
|
|
1875
|
+
assistantBranchId: assistant.branchStableId,
|
|
1876
|
+
assistantParentTurnId: assistant.parentStableId,
|
|
1877
|
+
baselineSnapshotDigest: durableBaseline.baseline.snapshotDigest,
|
|
1878
|
+
preparedDigest,
|
|
1879
|
+
baseline: material.baseline
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
async readControlParent(request) {
|
|
1883
|
+
const loaded = await this.journal.load(request.operationId, request.parentRequestDigest);
|
|
1884
|
+
const handle = await this.journal.handleFromState(loaded.state);
|
|
1885
|
+
await this.journal.validateHandle({
|
|
1886
|
+
...handle,
|
|
1887
|
+
schemaVersion: OPERATION_HANDLE_SCHEMA_VERSION,
|
|
1888
|
+
operationId: request.operationId,
|
|
1889
|
+
requestDigest: request.parentRequestDigest,
|
|
1890
|
+
surface: loaded.state.surface,
|
|
1891
|
+
revision: loaded.state.revision,
|
|
1892
|
+
phase: loaded.state.phase,
|
|
1893
|
+
mutationBoundary: loaded.state.mutationBoundary,
|
|
1894
|
+
...(request.parentTargetBindingDigest === undefined ? {} : { targetBindingDigest: request.parentTargetBindingDigest })
|
|
1895
|
+
}, loaded.state);
|
|
1896
|
+
if (loaded.state.target?.targetLifecycle === "new_pending") {
|
|
1897
|
+
throw new OperationServiceError("target_establishment_required", "Control requires durable provider identity establishment for a new target.");
|
|
1898
|
+
}
|
|
1899
|
+
if (request.action === "steer" && loaded.state.surface !== "work") {
|
|
1900
|
+
throw new OperationServiceError("operation_request_mismatch", "Work steer requires a durable Work operation.");
|
|
1901
|
+
}
|
|
1902
|
+
if (request.action === "steer") {
|
|
1903
|
+
// A satisfied Work action without its authenticated baseline+witness is
|
|
1904
|
+
// a corrupt prefix, not an invitation to prepare another provider read.
|
|
1905
|
+
// Quarantine it at the parent-read boundary so a fresh caller cannot
|
|
1906
|
+
// even invoke the adapter's preparation port before the inconsistency
|
|
1907
|
+
// is surfaced.
|
|
1908
|
+
for (const prior of Object.values(loaded.state.actions)) {
|
|
1909
|
+
if (prior.kind === "work_steer"
|
|
1910
|
+
&& (prior.outcome === "satisfied" || prior.outcome === "not_satisfied")) {
|
|
1911
|
+
priorWorkSteerDisposition(loaded.state, prior);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
const action = loaded.state.actions[request.controlActionId];
|
|
1916
|
+
if (action !== undefined && (action.kind !== (request.action === "steer" ? "work_steer" : "stop")
|
|
1917
|
+
|| action.requestDigest !== request.requestDigest
|
|
1918
|
+
|| action.targetDigest !== request.parentTargetBindingDigest)) {
|
|
1919
|
+
throw new OperationServiceError("operation_request_mismatch", "Control action identity conflicts with durable state.");
|
|
1920
|
+
}
|
|
1921
|
+
let existingSteerIntent;
|
|
1922
|
+
if (request.action === "steer" && action !== undefined) {
|
|
1923
|
+
const witness = submissionWitnessForAction(loaded.state, action);
|
|
1924
|
+
if (action.outcome === "satisfied" && witness === undefined) {
|
|
1925
|
+
// A generic satisfied action without its causal Work witness is an
|
|
1926
|
+
// impossible/unsafe prefix. Never let a replay interpret it as a
|
|
1927
|
+
// completed control or invoke a browser mutation again.
|
|
1928
|
+
throw new OperationServiceError("operation_state_corrupt", "Satisfied Work-steer action is missing its durable submission witness.");
|
|
1929
|
+
}
|
|
1930
|
+
existingSteerIntent = await this.reconstructSteerIntent(loaded.state, action.actionId, request.parentRequestDigest, request.parentTargetBindingDigest, request.requestDigest, request.expectedAssistantTurnId);
|
|
1931
|
+
if (witness !== undefined) {
|
|
1932
|
+
const baseline = loaded.state.ownershipBaselines?.[action.actionId];
|
|
1933
|
+
if (baseline === undefined
|
|
1934
|
+
|| witness.actionKind !== "work_steer"
|
|
1935
|
+
|| witness.targetBindingDigest !== request.parentTargetBindingDigest
|
|
1936
|
+
|| witness.baselineSnapshotDigest !== baseline.baseline.snapshotDigest) {
|
|
1937
|
+
throw new OperationServiceError("operation_state_corrupt", "Durable Work-steer witness does not match its prepared baseline.");
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
const existingReceipt = action?.outcome === undefined || action === undefined
|
|
1942
|
+
? undefined
|
|
1943
|
+
: controlReceiptFromAction(request, action, action.receiptAt);
|
|
1944
|
+
return {
|
|
1945
|
+
state: loaded.state,
|
|
1946
|
+
handle,
|
|
1947
|
+
...(existingReceipt === undefined ? {} : { existingReceipt }),
|
|
1948
|
+
...(existingSteerIntent === undefined ? {} : { existingSteerIntent })
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
async persistControlIntent(request) {
|
|
1952
|
+
const parent = await this.journal.load(request.operationId, request.parentRequestDigest);
|
|
1953
|
+
await this.persistActionIntent({
|
|
1954
|
+
operationId: request.operationId,
|
|
1955
|
+
requestDigest: request.requestDigest,
|
|
1956
|
+
durableRequestDigest: request.parentRequestDigest,
|
|
1957
|
+
surface: parent.state.surface,
|
|
1958
|
+
actionId: request.controlActionId,
|
|
1959
|
+
kind: request.action === "steer" ? "work_steer" : "stop",
|
|
1960
|
+
repeatPolicy: "observe_only_after_intent",
|
|
1961
|
+
targetBindingDigest: request.targetBindingDigest
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
async persistControlReceipt(request) {
|
|
1965
|
+
const receipt = request.receipt;
|
|
1966
|
+
if (receipt.schemaVersion !== OPERATION_CONTROL_RECEIPT_SCHEMA_VERSION
|
|
1967
|
+
|| receipt.action !== "stop" && receipt.action !== "steer"
|
|
1968
|
+
|| !UUID_PATTERN.test(receipt.parentOperationId)
|
|
1969
|
+
|| !DIGEST_PATTERN.test(receipt.parentRequestDigest)
|
|
1970
|
+
|| !DIGEST_PATTERN.test(receipt.parentTargetBindingDigest)
|
|
1971
|
+
|| !DIGEST_PATTERN.test(receipt.requestDigest)
|
|
1972
|
+
|| !UUID_PATTERN.test(receipt.controlActionId)
|
|
1973
|
+
|| !isSafeOpaqueId(receipt.expectedAssistantTurnId)
|
|
1974
|
+
|| !isCanonicalTimestamp(receipt.observedAt)) {
|
|
1975
|
+
throw new OperationServiceError("operation_state_corrupt", "Control receipt identity is invalid.");
|
|
1976
|
+
}
|
|
1977
|
+
const current = await this.journal.load(receipt.parentOperationId, receipt.parentRequestDigest);
|
|
1978
|
+
const action = current.state.actions[receipt.controlActionId];
|
|
1979
|
+
if (action === undefined
|
|
1980
|
+
|| action.kind !== (receipt.action === "steer" ? "work_steer" : "stop")
|
|
1981
|
+
|| action.requestDigest !== receipt.requestDigest
|
|
1982
|
+
|| action.targetDigest !== receipt.parentTargetBindingDigest) {
|
|
1983
|
+
throw new OperationServiceError("operation_request_mismatch", "Control receipt does not match its durable action identity.");
|
|
1984
|
+
}
|
|
1985
|
+
if (receipt.action === "steer") {
|
|
1986
|
+
if (receipt.outcome !== "satisfied") {
|
|
1987
|
+
if (request.steerReceipt !== undefined) {
|
|
1988
|
+
throw new OperationServiceError("operation_state_corrupt", "A non-satisfied Work-steer receipt cannot carry satisfied witness evidence.");
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
else {
|
|
1992
|
+
if (request.steerReceipt === undefined) {
|
|
1993
|
+
throw new OperationServiceError("submission_witness_missing", "A satisfied Work-steer receipt requires its rich causal witness.");
|
|
1994
|
+
}
|
|
1995
|
+
const prepared = await this.reconstructSteerIntent(current.state, action.actionId, receipt.parentRequestDigest, receipt.parentTargetBindingDigest, receipt.requestDigest, receipt.expectedAssistantTurnId);
|
|
1996
|
+
validateSteerVerificationReceipt(request.steerReceipt, prepared, receipt);
|
|
1997
|
+
const witness = {
|
|
1998
|
+
schemaVersion: OPERATION_SUBMISSION_WITNESS_SCHEMA_VERSION,
|
|
1999
|
+
actionId: action.actionId,
|
|
2000
|
+
actionKind: "work_steer",
|
|
2001
|
+
targetBindingDigest: receipt.parentTargetBindingDigest,
|
|
2002
|
+
baselineSnapshotDigest: request.steerReceipt.baselineSnapshotDigest,
|
|
2003
|
+
postSendDeltaDigest: request.steerReceipt.postSendDeltaDigest,
|
|
2004
|
+
operationUserEvidenceDigest: request.steerReceipt.userTurnEvidenceDigest,
|
|
2005
|
+
userTurnId: request.steerReceipt.userTurnId,
|
|
2006
|
+
observedAt: receipt.observedAt
|
|
2007
|
+
};
|
|
2008
|
+
// Causal ownership must be durable before the generic action receipt;
|
|
2009
|
+
// a crash after this append cannot leave a satisfied steer without
|
|
2010
|
+
// the witness needed to classify its output.
|
|
2011
|
+
await this.appendSubmissionWitnessConvergent(receipt.parentOperationId, receipt.parentRequestDigest, witness);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
else if (request.steerReceipt !== undefined) {
|
|
2015
|
+
throw new OperationServiceError("operation_state_corrupt", "Stop receipts cannot carry Work-steer witness evidence.");
|
|
2016
|
+
}
|
|
2017
|
+
await this.appendActionReceiptConvergent(receipt.parentOperationId, receipt.parentRequestDigest, receipt.controlActionId, receipt.outcome, receipt.evidenceDigest, receipt.blockerCode, receipt.observedAt);
|
|
2018
|
+
const after = await this.journal.load(receipt.parentOperationId, receipt.parentRequestDigest);
|
|
2019
|
+
if (after.state.actions[receipt.controlActionId]?.outcome !== receipt.outcome) {
|
|
2020
|
+
throw new OperationServiceError("journal_unavailable", "Control receipt was not durably validated.");
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
async loadForHandle(handle) {
|
|
2024
|
+
if (!handle || handle.schemaVersion !== OPERATION_HANDLE_SCHEMA_VERSION) {
|
|
2025
|
+
throw new OperationServiceError("invalid_operation_handle", "Operation handle schema is unsupported.");
|
|
2026
|
+
}
|
|
2027
|
+
let loaded;
|
|
2028
|
+
try {
|
|
2029
|
+
loaded = await this.journal.load(handle.operationId, handle.requestDigest);
|
|
2030
|
+
await this.journal.validateHandle(handle, loaded.state);
|
|
2031
|
+
}
|
|
2032
|
+
catch (error) {
|
|
2033
|
+
throw this.serviceError(error, "invalid_operation_handle");
|
|
2034
|
+
}
|
|
2035
|
+
return loaded;
|
|
2036
|
+
}
|
|
2037
|
+
async targetBindingDigest(state) {
|
|
2038
|
+
const digest = (await this.journal.handleFromState(state)).targetBindingDigest;
|
|
2039
|
+
if (digest === undefined)
|
|
2040
|
+
throw new OperationServiceError("target_binding_missing", "Operation has no durable target binding.");
|
|
2041
|
+
return digest;
|
|
2042
|
+
}
|
|
2043
|
+
timestamp(notBefore) {
|
|
2044
|
+
const value = this.now();
|
|
2045
|
+
if (!Number.isFinite(value))
|
|
2046
|
+
throw new OperationServiceError("invalid_clock", "Operation service clock returned a non-finite value.");
|
|
2047
|
+
if (notBefore === undefined)
|
|
2048
|
+
return new Date(value).toISOString();
|
|
2049
|
+
const floor = Date.parse(notBefore);
|
|
2050
|
+
if (!Number.isFinite(floor))
|
|
2051
|
+
throw new OperationServiceError("invalid_timestamp", "Durable state timestamp is invalid.");
|
|
2052
|
+
return new Date(Math.max(value, floor)).toISOString();
|
|
2053
|
+
}
|
|
2054
|
+
serviceError(error, fallback) {
|
|
2055
|
+
const fallbackCode = safeServiceErrorCode(fallback) ?? "journal_unavailable";
|
|
2056
|
+
const code = safeServiceErrorCode(readOwnErrorCode(error)) ?? fallbackCode;
|
|
2057
|
+
const message = SAFE_SERVICE_ERROR_MESSAGES[code]
|
|
2058
|
+
?? SAFE_SERVICE_ERROR_MESSAGES[fallbackCode]
|
|
2059
|
+
?? "The operation failed safely.";
|
|
2060
|
+
return new OperationServiceError(code, message);
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
function readOwnErrorCode(error) {
|
|
2064
|
+
if (!(error instanceof OperationServiceError) && !(error instanceof OperationJournalError))
|
|
2065
|
+
return undefined;
|
|
2066
|
+
try {
|
|
2067
|
+
const descriptor = Object.getOwnPropertyDescriptor(error, "code");
|
|
2068
|
+
return descriptor !== undefined && "value" in descriptor && typeof descriptor.value === "string"
|
|
2069
|
+
? descriptor.value
|
|
2070
|
+
: undefined;
|
|
2071
|
+
}
|
|
2072
|
+
catch {
|
|
2073
|
+
return undefined;
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
function safeServiceErrorCode(code) {
|
|
2077
|
+
return code !== undefined
|
|
2078
|
+
&& code !== "default"
|
|
2079
|
+
&& Object.prototype.hasOwnProperty.call(SAFE_SERVICE_ERROR_MESSAGES, code)
|
|
2080
|
+
? code
|
|
2081
|
+
: undefined;
|
|
2082
|
+
}
|
|
2083
|
+
function uniqueAction(state, kind) {
|
|
2084
|
+
const matches = Object.values(state.actions).filter(action => action.kind === kind);
|
|
2085
|
+
if (matches.length > 1)
|
|
2086
|
+
throw new OperationServiceError("operation_state_corrupt", `Operation contains duplicate ${kind} actions.`);
|
|
2087
|
+
return matches[0];
|
|
2088
|
+
}
|
|
2089
|
+
function originalSendAction(state) {
|
|
2090
|
+
return uniqueAction(state, "send");
|
|
2091
|
+
}
|
|
2092
|
+
function submissionWitnessForAction(state, action) {
|
|
2093
|
+
const mapped = state.submissionWitnesses?.[action.actionId];
|
|
2094
|
+
if (mapped !== undefined)
|
|
2095
|
+
return mapped;
|
|
2096
|
+
if (action.kind === "send" && state.submissionWitness?.actionId === action.actionId) {
|
|
2097
|
+
return state.submissionWitness;
|
|
2098
|
+
}
|
|
2099
|
+
return undefined;
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* A Work steer may be followed by another caller-owned steer only after its
|
|
2103
|
+
* outcome is durably settled. Satisfied actions require the complete causal
|
|
2104
|
+
* baseline+witness pair; a clean not_satisfied action is safe to ignore and
|
|
2105
|
+
* intentionally has no witness. Missing or mismatched evidence on a
|
|
2106
|
+
* satisfied action is corruption, never permission to continue.
|
|
2107
|
+
*/
|
|
2108
|
+
function priorWorkSteerDisposition(state, action) {
|
|
2109
|
+
if (action.kind !== "work_steer") {
|
|
2110
|
+
throw new OperationServiceError("operation_state_corrupt", "Work-steer disposition was requested for a non-steer action.");
|
|
2111
|
+
}
|
|
2112
|
+
if (action.outcome === undefined || action.outcome === "uncertain")
|
|
2113
|
+
return "unresolved";
|
|
2114
|
+
if (action.outcome === "not_satisfied") {
|
|
2115
|
+
if (submissionWitnessForAction(state, action) !== undefined) {
|
|
2116
|
+
throw new OperationServiceError("operation_state_corrupt", "A rejected Work-steer action cannot carry a submission witness.");
|
|
2117
|
+
}
|
|
2118
|
+
return "settled";
|
|
2119
|
+
}
|
|
2120
|
+
const baseline = state.ownershipBaselines?.[action.actionId];
|
|
2121
|
+
const witness = submissionWitnessForAction(state, action);
|
|
2122
|
+
if (baseline === undefined || witness === undefined) {
|
|
2123
|
+
throw new OperationServiceError("operation_state_corrupt", "Satisfied Work-steer action is missing its durable ownership pair.");
|
|
2124
|
+
}
|
|
2125
|
+
if (baseline.operationId !== state.operationId
|
|
2126
|
+
|| baseline.requestDigest !== state.requestDigest
|
|
2127
|
+
|| baseline.actionId !== action.actionId
|
|
2128
|
+
|| baseline.targetBindingDigest !== action.targetDigest
|
|
2129
|
+
|| witness.actionId !== action.actionId
|
|
2130
|
+
|| witness.actionKind !== "work_steer"
|
|
2131
|
+
|| witness.targetBindingDigest !== action.targetDigest
|
|
2132
|
+
|| witness.baselineSnapshotDigest !== baseline.baseline.snapshotDigest) {
|
|
2133
|
+
throw new OperationServiceError("operation_state_corrupt", "Satisfied Work-steer ownership evidence does not match its action.");
|
|
2134
|
+
}
|
|
2135
|
+
return "settled";
|
|
2136
|
+
}
|
|
2137
|
+
function assertCollectorOwnership(durable, active) {
|
|
2138
|
+
if (active === undefined || durable.submissionWitness === undefined) {
|
|
2139
|
+
throw new OperationServiceError("submission_witness_missing", "Collector persistence requires the authenticated causal ownership pair.");
|
|
2140
|
+
}
|
|
2141
|
+
const projected = ownershipWitnessFromDurable(active.witness);
|
|
2142
|
+
const supplied = durable.submissionWitness;
|
|
2143
|
+
if (supplied.actionId !== projected.actionId
|
|
2144
|
+
|| supplied.actionKind !== projected.actionKind
|
|
2145
|
+
|| supplied.baselineSnapshotDigest !== projected.baselineSnapshotDigest
|
|
2146
|
+
|| supplied.postSendDeltaDigest !== projected.postSendDeltaDigest
|
|
2147
|
+
|| supplied.operationUserEvidenceDigest !== projected.operationUserEvidenceDigest
|
|
2148
|
+
|| supplied.userTurnStableId !== projected.userTurnStableId
|
|
2149
|
+
|| canonicalJson(durable.baseline) !== canonicalJson(active.baseline.baseline)) {
|
|
2150
|
+
throw new OperationServiceError("operation_state_corrupt", "Collector persistence ownership does not match the latest authenticated causal action.");
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Select the latest non-rejected causal action that has a complete
|
|
2155
|
+
* authenticated ownership pair. The original Send remains the operation's
|
|
2156
|
+
* completion cause, but a Work steer can supersede it for turn
|
|
2157
|
+
* classification—even in the crash window after its witness is durable and
|
|
2158
|
+
* before its generic action receipt—so the steer's user turn/output delta is
|
|
2159
|
+
* not mistaken for a concurrent user turn. Missing evidence on a candidate is
|
|
2160
|
+
* corruption, not a reason to fall back to an older action.
|
|
2161
|
+
*/
|
|
2162
|
+
function latestSubmissionOwnership(state) {
|
|
2163
|
+
// A Work steer that has crossed its durable fence but has not yet produced
|
|
2164
|
+
// its witness is an unresolved ownership boundary. Falling back to the
|
|
2165
|
+
// original Send here could classify a post-steer turn against the wrong
|
|
2166
|
+
// baseline, so quarantine the collector until the exact Work pair is
|
|
2167
|
+
// available. A rejected steer is safe to ignore because it crossed no
|
|
2168
|
+
// satisfied output boundary.
|
|
2169
|
+
for (const action of Object.values(state.actions)) {
|
|
2170
|
+
if (action.kind !== "work_steer" || action.outcome === "not_satisfied")
|
|
2171
|
+
continue;
|
|
2172
|
+
const baseline = state.ownershipBaselines?.[action.actionId];
|
|
2173
|
+
const witness = state.submissionWitnesses?.[action.actionId];
|
|
2174
|
+
if (baseline === undefined || witness === undefined) {
|
|
2175
|
+
throw new OperationServiceError("submission_witness_missing", "A fenced Work-steer action is missing its complete causal ownership evidence.");
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
const candidates = Object.values(state.actions)
|
|
2179
|
+
.filter((action) => (action.kind === "send" || action.kind === "work_steer")
|
|
2180
|
+
&& action.outcome !== "not_satisfied"
|
|
2181
|
+
&& action.outcome !== "uncertain");
|
|
2182
|
+
if (candidates.length === 0)
|
|
2183
|
+
return undefined;
|
|
2184
|
+
const ownershipBaselines = state.ownershipBaselines;
|
|
2185
|
+
const submissionWitnesses = state.submissionWitnesses;
|
|
2186
|
+
const resolved = [];
|
|
2187
|
+
for (const action of candidates) {
|
|
2188
|
+
const baseline = ownershipBaselines?.[action.actionId]
|
|
2189
|
+
?? (action.kind === "send" && state.ownershipBaseline?.actionId === action.actionId
|
|
2190
|
+
? state.ownershipBaseline
|
|
2191
|
+
: undefined);
|
|
2192
|
+
const witness = submissionWitnesses?.[action.actionId]
|
|
2193
|
+
?? (action.kind === "send" && state.submissionWitness?.actionId === action.actionId
|
|
2194
|
+
? state.submissionWitness
|
|
2195
|
+
: undefined);
|
|
2196
|
+
if (baseline === undefined || witness === undefined) {
|
|
2197
|
+
throw new OperationServiceError("submission_witness_missing", `Satisfied ${action.kind} action is missing its durable ownership evidence.`);
|
|
2198
|
+
}
|
|
2199
|
+
if (baseline.actionId !== action.actionId
|
|
2200
|
+
|| baseline.targetBindingDigest !== action.targetDigest
|
|
2201
|
+
|| witness.actionId !== action.actionId
|
|
2202
|
+
|| witness.actionKind !== action.kind
|
|
2203
|
+
|| witness.targetBindingDigest !== action.targetDigest
|
|
2204
|
+
|| witness.baselineSnapshotDigest !== baseline.baseline.snapshotDigest) {
|
|
2205
|
+
throw new OperationServiceError("operation_state_corrupt", `Durable ownership evidence does not match satisfied ${action.kind} action.`);
|
|
2206
|
+
}
|
|
2207
|
+
resolved.push({ action, baseline, witness });
|
|
2208
|
+
}
|
|
2209
|
+
resolved.sort((left, right) => {
|
|
2210
|
+
const leftRevision = left.action.receiptRevision ?? left.action.intentRevision;
|
|
2211
|
+
const rightRevision = right.action.receiptRevision ?? right.action.intentRevision;
|
|
2212
|
+
if (leftRevision !== rightRevision)
|
|
2213
|
+
return rightRevision - leftRevision;
|
|
2214
|
+
return left.action.actionId < right.action.actionId ? 1 : left.action.actionId > right.action.actionId ? -1 : 0;
|
|
2215
|
+
});
|
|
2216
|
+
return resolved[0];
|
|
2217
|
+
}
|
|
2218
|
+
function ownershipWitnessFromDurable(witness) {
|
|
2219
|
+
return {
|
|
2220
|
+
actionId: witness.actionId,
|
|
2221
|
+
actionKind: witness.actionKind,
|
|
2222
|
+
baselineSnapshotDigest: witness.baselineSnapshotDigest,
|
|
2223
|
+
postSendDeltaDigest: witness.postSendDeltaDigest,
|
|
2224
|
+
operationUserEvidenceDigest: witness.operationUserEvidenceDigest,
|
|
2225
|
+
...(witness.userTurnId === undefined ? {} : { userTurnStableId: witness.userTurnId })
|
|
2226
|
+
};
|
|
2227
|
+
}
|
|
2228
|
+
function submissionWitnessMatchesReceipt(witness, receipt, actionId, baselineSnapshotDigest) {
|
|
2229
|
+
return witness.actionId === actionId
|
|
2230
|
+
&& witness.actionKind === "send"
|
|
2231
|
+
&& witness.targetBindingDigest === receipt.targetBindingDigest
|
|
2232
|
+
&& witness.baselineSnapshotDigest === baselineSnapshotDigest
|
|
2233
|
+
&& witness.postSendDeltaDigest === receipt.postSendDeltaDigest
|
|
2234
|
+
&& witness.operationUserEvidenceDigest === receipt.userTurnEvidenceDigest
|
|
2235
|
+
&& (witness.userTurnId === undefined || witness.userTurnId === receipt.userTurnId);
|
|
2236
|
+
}
|
|
2237
|
+
function submissionWitnessMatchesEstablishment(witness, establishment, baselineSnapshotDigest) {
|
|
2238
|
+
return witness.actionId === establishment.causalSendActionId
|
|
2239
|
+
&& witness.actionKind === "send"
|
|
2240
|
+
&& witness.targetBindingDigest === establishment.targetBindingDigest
|
|
2241
|
+
&& witness.baselineSnapshotDigest === baselineSnapshotDigest
|
|
2242
|
+
&& witness.postSendDeltaDigest === establishment.postSendDeltaDigest
|
|
2243
|
+
&& witness.operationUserEvidenceDigest === establishment.userTurnEvidenceDigest
|
|
2244
|
+
&& (witness.userTurnId === undefined || witness.userTurnId === establishment.userTurnId);
|
|
2245
|
+
}
|
|
2246
|
+
function actionMatchesIntent(action, request) {
|
|
2247
|
+
return action.actionId === request.actionId
|
|
2248
|
+
&& action.kind === request.kind
|
|
2249
|
+
&& action.repeatPolicy === request.repeatPolicy
|
|
2250
|
+
&& action.requestDigest === request.requestDigest
|
|
2251
|
+
&& action.targetDigest === request.targetBindingDigest;
|
|
2252
|
+
}
|
|
2253
|
+
function conflictingActionForIntent(state, request) {
|
|
2254
|
+
if (request.kind === "file_handoff" || request.kind === "send") {
|
|
2255
|
+
return Object.values(state.actions).find(action => action.kind === request.kind);
|
|
2256
|
+
}
|
|
2257
|
+
if (request.kind === "configuration_set"
|
|
2258
|
+
|| request.kind === "tool_set"
|
|
2259
|
+
|| request.kind === "composer_set"
|
|
2260
|
+
|| request.kind === "power_select") {
|
|
2261
|
+
return Object.values(state.actions).find(action => action.kind === request.kind && action.outcome === undefined);
|
|
2262
|
+
}
|
|
2263
|
+
return undefined;
|
|
2264
|
+
}
|
|
2265
|
+
function phaseReached(current, desired) {
|
|
2266
|
+
if (current === desired)
|
|
2267
|
+
return true;
|
|
2268
|
+
const order = ["prepared", "handoff_pending", "ready", "send_pending", "submitted", "generating", "capturing", "completed"];
|
|
2269
|
+
if (desired === "uncertain")
|
|
2270
|
+
return current === "uncertain" || current === "completed";
|
|
2271
|
+
if (current === "uncertain")
|
|
2272
|
+
return false;
|
|
2273
|
+
const currentIndex = order.indexOf(current);
|
|
2274
|
+
const desiredIndex = order.indexOf(desired);
|
|
2275
|
+
return currentIndex >= 0 && desiredIndex >= 0 && currentIndex > desiredIndex;
|
|
2276
|
+
}
|
|
2277
|
+
function isRevisionConflict(error) {
|
|
2278
|
+
return error instanceof OperationJournalError && error.code === "revision_conflict";
|
|
2279
|
+
}
|
|
2280
|
+
function validateTargetResolution(value) {
|
|
2281
|
+
if (!value || typeof value !== "object" || value.target === undefined) {
|
|
2282
|
+
throw new OperationServiceError("invalid_target_resolution", "Target resolution is incomplete.");
|
|
2283
|
+
}
|
|
2284
|
+
if (value.configurationReceiptDigest !== undefined)
|
|
2285
|
+
assertDigest(value.configurationReceiptDigest, "configurationReceiptDigest");
|
|
2286
|
+
if (value.composerReceiptDigest !== undefined)
|
|
2287
|
+
assertDigest(value.composerReceiptDigest, "composerReceiptDigest");
|
|
2288
|
+
}
|
|
2289
|
+
function validateSubmissionWitnessInput(value) {
|
|
2290
|
+
if (value === null
|
|
2291
|
+
|| typeof value !== "object"
|
|
2292
|
+
|| Array.isArray(value)
|
|
2293
|
+
|| value.schemaVersion !== OPERATION_SUBMISSION_WITNESS_SCHEMA_VERSION
|
|
2294
|
+
|| !UUID_PATTERN.test(value.actionId)
|
|
2295
|
+
|| (value.actionKind !== "send" && value.actionKind !== "work_steer")
|
|
2296
|
+
|| !DIGEST_PATTERN.test(value.targetBindingDigest)
|
|
2297
|
+
|| !DIGEST_PATTERN.test(value.baselineSnapshotDigest)
|
|
2298
|
+
|| !DIGEST_PATTERN.test(value.postSendDeltaDigest)
|
|
2299
|
+
|| !DIGEST_PATTERN.test(value.operationUserEvidenceDigest)
|
|
2300
|
+
|| (value.userTurnId !== undefined && !isSafeOpaqueId(value.userTurnId))
|
|
2301
|
+
|| !isCanonicalTimestamp(value.observedAt)) {
|
|
2302
|
+
throw new OperationServiceError("invalid_submission_witness", "Submission witness evidence is invalid.");
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
function isSafeOpaqueId(value) {
|
|
2306
|
+
return typeof value === "string"
|
|
2307
|
+
&& value.length > 0
|
|
2308
|
+
&& value.length <= 512
|
|
2309
|
+
&& !/[\u0000-\u001f\u007f]/u.test(value);
|
|
2310
|
+
}
|
|
2311
|
+
function validateTargetEstablishmentRequest(value) {
|
|
2312
|
+
const normalized = readPlainServiceDataRecord(value);
|
|
2313
|
+
if (normalized === undefined) {
|
|
2314
|
+
throw new OperationServiceError("invalid_target_establishment", "Target establishment request is invalid.");
|
|
2315
|
+
}
|
|
2316
|
+
const allowedKeys = new Set([
|
|
2317
|
+
"operationId",
|
|
2318
|
+
"requestDigest",
|
|
2319
|
+
"targetBindingDigest",
|
|
2320
|
+
"anchorDigest",
|
|
2321
|
+
"causalSendActionId",
|
|
2322
|
+
"conversationId",
|
|
2323
|
+
"canonicalThreadUrl",
|
|
2324
|
+
"userTurnId",
|
|
2325
|
+
"userTurnEvidenceDigest",
|
|
2326
|
+
"postSendDeltaDigest",
|
|
2327
|
+
"evidenceDigest",
|
|
2328
|
+
"observedAt"
|
|
2329
|
+
]);
|
|
2330
|
+
for (const key of Object.keys(normalized)) {
|
|
2331
|
+
if (!allowedKeys.has(key)) {
|
|
2332
|
+
throw new OperationServiceError("invalid_target_establishment", "Target establishment request contains unsupported fields.");
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
for (const [label, candidate] of [
|
|
2336
|
+
["operationId", normalized.operationId],
|
|
2337
|
+
["causalSendActionId", normalized.causalSendActionId],
|
|
2338
|
+
["conversationId", normalized.conversationId],
|
|
2339
|
+
["userTurnId", normalized.userTurnId]
|
|
2340
|
+
]) {
|
|
2341
|
+
if (typeof candidate !== "string" || candidate.trim().length === 0 || candidate.length > 512 || /[\u0000-\u001f\u007f]/u.test(candidate)) {
|
|
2342
|
+
throw new OperationServiceError("invalid_target_establishment", `${label} is invalid.`);
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
if (!UUID_PATTERN.test(normalized.operationId) || !UUID_PATTERN.test(normalized.causalSendActionId)) {
|
|
2346
|
+
throw new OperationServiceError("invalid_target_establishment", "Target establishment operation/action identity is invalid.");
|
|
2347
|
+
}
|
|
2348
|
+
for (const [label, digest] of [
|
|
2349
|
+
["requestDigest", normalized.requestDigest],
|
|
2350
|
+
["targetBindingDigest", normalized.targetBindingDigest],
|
|
2351
|
+
["anchorDigest", normalized.anchorDigest],
|
|
2352
|
+
["userTurnEvidenceDigest", normalized.userTurnEvidenceDigest],
|
|
2353
|
+
["postSendDeltaDigest", normalized.postSendDeltaDigest],
|
|
2354
|
+
["evidenceDigest", normalized.evidenceDigest]
|
|
2355
|
+
]) {
|
|
2356
|
+
if (typeof digest !== "string" || !DIGEST_PATTERN.test(digest)) {
|
|
2357
|
+
throw new OperationServiceError("invalid_target_establishment", `${label} is invalid.`);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
if (typeof normalized.canonicalThreadUrl !== "string" || normalized.canonicalThreadUrl.length === 0 || normalized.canonicalThreadUrl.length > 4096) {
|
|
2361
|
+
throw new OperationServiceError("invalid_target_establishment", "canonicalThreadUrl is invalid.");
|
|
2362
|
+
}
|
|
2363
|
+
let parsed;
|
|
2364
|
+
try {
|
|
2365
|
+
parsed = new URL(normalized.canonicalThreadUrl);
|
|
2366
|
+
}
|
|
2367
|
+
catch {
|
|
2368
|
+
throw new OperationServiceError("invalid_target_establishment", "canonicalThreadUrl is invalid.");
|
|
2369
|
+
}
|
|
2370
|
+
if (parsed.protocol !== "https:"
|
|
2371
|
+
|| parsed.username !== ""
|
|
2372
|
+
|| parsed.password !== ""
|
|
2373
|
+
|| parsed.search !== ""
|
|
2374
|
+
|| parsed.hash !== ""
|
|
2375
|
+
|| parsed.toString() !== normalized.canonicalThreadUrl) {
|
|
2376
|
+
throw new OperationServiceError("invalid_target_establishment", "canonicalThreadUrl must be canonical HTTPS without credentials, query, or fragment.");
|
|
2377
|
+
}
|
|
2378
|
+
if (normalized.observedAt !== undefined && (typeof normalized.observedAt !== "string" || !isCanonicalTimestamp(normalized.observedAt))) {
|
|
2379
|
+
throw new OperationServiceError("invalid_target_establishment", "observedAt must be a canonical UTC timestamp.");
|
|
2380
|
+
}
|
|
2381
|
+
return Object.freeze(normalized);
|
|
2382
|
+
}
|
|
2383
|
+
function isCanonicalTimestamp(value) {
|
|
2384
|
+
const parsed = Date.parse(value);
|
|
2385
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value)
|
|
2386
|
+
&& Number.isFinite(parsed)
|
|
2387
|
+
&& new Date(parsed).toISOString() === value;
|
|
2388
|
+
}
|
|
2389
|
+
function readPlainServiceDataRecord(value) {
|
|
2390
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
2391
|
+
return undefined;
|
|
2392
|
+
try {
|
|
2393
|
+
const prototype = Object.getPrototypeOf(value);
|
|
2394
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
2395
|
+
return undefined;
|
|
2396
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
2397
|
+
const normalized = Object.create(null);
|
|
2398
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
2399
|
+
if (typeof key !== "string")
|
|
2400
|
+
return undefined;
|
|
2401
|
+
const descriptor = descriptors[key];
|
|
2402
|
+
if (descriptor === undefined
|
|
2403
|
+
|| descriptor.get !== undefined
|
|
2404
|
+
|| descriptor.set !== undefined
|
|
2405
|
+
|| !("value" in descriptor))
|
|
2406
|
+
return undefined;
|
|
2407
|
+
normalized[key] = descriptor.value;
|
|
2408
|
+
}
|
|
2409
|
+
return normalized;
|
|
2410
|
+
}
|
|
2411
|
+
catch {
|
|
2412
|
+
return undefined;
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
function assertDigest(value, label) {
|
|
2416
|
+
if (!DIGEST_PATTERN.test(value))
|
|
2417
|
+
throw new OperationServiceError("invalid_digest", `${label} must be a canonical HMAC digest.`);
|
|
2418
|
+
}
|
|
2419
|
+
function isAbortSignal(value) {
|
|
2420
|
+
return value !== null && typeof value === "object" && typeof value.aborted === "boolean" && typeof value.addEventListener === "function";
|
|
2421
|
+
}
|
|
2422
|
+
function submissionFromCompleted(state) {
|
|
2423
|
+
const receipt = state.receipt;
|
|
2424
|
+
if (receipt === undefined)
|
|
2425
|
+
throw new OperationServiceError("operation_state_corrupt", "Completed operation has no receipt.");
|
|
2426
|
+
const action = originalSendAction(state);
|
|
2427
|
+
return {
|
|
2428
|
+
operationId: state.operationId,
|
|
2429
|
+
requestDigest: state.requestDigest,
|
|
2430
|
+
surface: state.surface,
|
|
2431
|
+
targetBindingDigest: receipt.targetBindingDigest,
|
|
2432
|
+
kind: "completed_receipt",
|
|
2433
|
+
...(action === undefined ? {} : { actionId: action.actionId }),
|
|
2434
|
+
evidenceDigest: receipt.ownershipEvidenceDigest,
|
|
2435
|
+
userTurnId: receipt.userTurnId,
|
|
2436
|
+
userTurnEvidenceDigest: receipt.userTurnEvidenceDigest,
|
|
2437
|
+
assistantTurnId: receipt.assistantTurnId
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
const TARGET_RESOLUTION_BLOCKER_CODES = new Set([
|
|
2441
|
+
"operation_cancelled",
|
|
2442
|
+
"operation_timeout",
|
|
2443
|
+
"operation_state_corrupt",
|
|
2444
|
+
"target_binding_mismatch",
|
|
2445
|
+
"target_evidence_unavailable",
|
|
2446
|
+
"configuration_drift",
|
|
2447
|
+
"composer_drift",
|
|
2448
|
+
"tab_ownership_conflict",
|
|
2449
|
+
"runtime_incompatible",
|
|
2450
|
+
"backend_unavailable",
|
|
2451
|
+
"browser_bridge_unavailable",
|
|
2452
|
+
"login_required",
|
|
2453
|
+
"captcha",
|
|
2454
|
+
"rate_limited",
|
|
2455
|
+
"permission_required",
|
|
2456
|
+
"needs_confirmation",
|
|
2457
|
+
"selector_drift",
|
|
2458
|
+
"journal_unavailable",
|
|
2459
|
+
"port_protocol_violation"
|
|
2460
|
+
]);
|
|
2461
|
+
const NON_RECOVERABLE_TARGET_BLOCKERS = new Set([
|
|
2462
|
+
"operation_state_corrupt",
|
|
2463
|
+
"target_binding_mismatch",
|
|
2464
|
+
"runtime_incompatible",
|
|
2465
|
+
"port_protocol_violation"
|
|
2466
|
+
]);
|
|
2467
|
+
/**
|
|
2468
|
+
* Convert a read-only target-resolution failure into a redacted operation
|
|
2469
|
+
* result without manufacturing a target binding. Error messages are never
|
|
2470
|
+
* copied: provider errors may contain URLs, account details, or request data.
|
|
2471
|
+
*/
|
|
2472
|
+
function submissionFromTargetResolutionFailure(state, handle, error, signal) {
|
|
2473
|
+
const code = targetResolutionBlockerCode(error, signal);
|
|
2474
|
+
const observationRequired = state.mutationBoundary !== "none"
|
|
2475
|
+
|| !NON_RECOVERABLE_TARGET_BLOCKERS.has(code);
|
|
2476
|
+
const blocker = {
|
|
2477
|
+
code,
|
|
2478
|
+
observationRequired,
|
|
2479
|
+
mutationBoundary: state.mutationBoundary
|
|
2480
|
+
};
|
|
2481
|
+
const identity = {
|
|
2482
|
+
operationId: state.operationId,
|
|
2483
|
+
requestDigest: state.requestDigest,
|
|
2484
|
+
surface: state.surface,
|
|
2485
|
+
...(handle.targetBindingDigest === undefined
|
|
2486
|
+
? {}
|
|
2487
|
+
: { targetBindingDigest: handle.targetBindingDigest })
|
|
2488
|
+
};
|
|
2489
|
+
if (code === "operation_cancelled" || code === "operation_timeout") {
|
|
2490
|
+
return {
|
|
2491
|
+
...identity,
|
|
2492
|
+
kind: "cancelled",
|
|
2493
|
+
blocker: { ...blocker, code }
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
if (state.phase === "uncertain" || state.mutationBoundary !== "none") {
|
|
2497
|
+
return { ...identity, kind: "uncertain", blocker };
|
|
2498
|
+
}
|
|
2499
|
+
return { ...identity, kind: "blocked", blocker };
|
|
2500
|
+
}
|
|
2501
|
+
function targetResolutionBlockerCode(error, signal) {
|
|
2502
|
+
if (signal.aborted)
|
|
2503
|
+
return "operation_cancelled";
|
|
2504
|
+
const code = safeOwnErrorCode(error);
|
|
2505
|
+
if (code !== undefined && TARGET_RESOLUTION_BLOCKER_CODES.has(code)) {
|
|
2506
|
+
return code;
|
|
2507
|
+
}
|
|
2508
|
+
switch (code) {
|
|
2509
|
+
case "rate_limit":
|
|
2510
|
+
return "rate_limited";
|
|
2511
|
+
case "page_affinity_mismatch":
|
|
2512
|
+
return "target_binding_mismatch";
|
|
2513
|
+
case "adapter_incomplete":
|
|
2514
|
+
case "capture_incomplete":
|
|
2515
|
+
case "unsupported_browser_primitive":
|
|
2516
|
+
case "not_initialized":
|
|
2517
|
+
case "invalid_target_resolution":
|
|
2518
|
+
case "invalid_digest":
|
|
2519
|
+
return "port_protocol_violation";
|
|
2520
|
+
case "adapter_unavailable":
|
|
2521
|
+
return "backend_unavailable";
|
|
2522
|
+
case "capture_failed":
|
|
2523
|
+
default:
|
|
2524
|
+
return "target_evidence_unavailable";
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
/** Read only an own data property so a hostile thrown accessor is never invoked. */
|
|
2528
|
+
function safeOwnErrorCode(error) {
|
|
2529
|
+
if (error === null || (typeof error !== "object" && typeof error !== "function"))
|
|
2530
|
+
return undefined;
|
|
2531
|
+
const descriptor = Object.getOwnPropertyDescriptor(error, "code");
|
|
2532
|
+
return descriptor !== undefined && "value" in descriptor && typeof descriptor.value === "string"
|
|
2533
|
+
? descriptor.value
|
|
2534
|
+
: undefined;
|
|
2535
|
+
}
|
|
2536
|
+
function completedFromState(handle, state) {
|
|
2537
|
+
const receipt = state.receipt;
|
|
2538
|
+
if (receipt === undefined)
|
|
2539
|
+
throw new OperationServiceError("operation_state_corrupt", "Completed operation has no receipt.");
|
|
2540
|
+
return {
|
|
2541
|
+
kind: "completed",
|
|
2542
|
+
operationId: state.operationId,
|
|
2543
|
+
requestDigest: state.requestDigest,
|
|
2544
|
+
targetBindingDigest: receipt.targetBindingDigest,
|
|
2545
|
+
attempts: 0,
|
|
2546
|
+
turn: {
|
|
2547
|
+
userTurnId: receipt.userTurnId,
|
|
2548
|
+
assistantTurnId: receipt.assistantTurnId,
|
|
2549
|
+
userTurnEvidenceDigest: receipt.userTurnEvidenceDigest,
|
|
2550
|
+
ownershipEvidenceDigest: receipt.ownershipEvidenceDigest
|
|
2551
|
+
},
|
|
2552
|
+
response: {
|
|
2553
|
+
contentAvailable: receipt.contentAvailable,
|
|
2554
|
+
rawContentAvailable: false,
|
|
2555
|
+
...(receipt.responseDigest === undefined ? {} : {
|
|
2556
|
+
text: {
|
|
2557
|
+
digest: receipt.responseDigest,
|
|
2558
|
+
...(receipt.responseBytes === undefined ? {} : { bytes: receipt.responseBytes })
|
|
2559
|
+
}
|
|
2560
|
+
}),
|
|
2561
|
+
artifacts: receipt.artifacts.map(artifact => ({
|
|
2562
|
+
kind: artifact.kind,
|
|
2563
|
+
ordinal: artifact.ordinal,
|
|
2564
|
+
sourceIdentityDigest: artifact.sourceIdentityDigest,
|
|
2565
|
+
...(artifact.sha256 === undefined ? {} : { contentDigest: `sha256:${artifact.sha256}` }),
|
|
2566
|
+
...(artifact.bytes === undefined ? {} : { bytes: artifact.bytes }),
|
|
2567
|
+
...(artifact.mimeType === undefined ? {} : { mimeType: artifact.mimeType })
|
|
2568
|
+
})),
|
|
2569
|
+
finishReason: receipt.finishReason
|
|
2570
|
+
}
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
function validateReceiptIdentity(receipt, operationId, requestDigest, targetBindingDigest) {
|
|
2574
|
+
if (receipt.schemaVersion !== OPERATION_RECEIPT_SCHEMA_VERSION
|
|
2575
|
+
|| receipt.operationId !== operationId
|
|
2576
|
+
|| receipt.requestDigest !== requestDigest
|
|
2577
|
+
|| receipt.targetBindingDigest !== targetBindingDigest) {
|
|
2578
|
+
throw new OperationServiceError("operation_receipt_mismatch", "Terminal receipt identity does not match the durable operation.");
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
function sameReceipt(left, right) {
|
|
2582
|
+
const { completedAt: _left, ...leftWithoutTime } = left;
|
|
2583
|
+
const { completedAt: _right, ...rightWithoutTime } = right;
|
|
2584
|
+
return canonicalJson(leftWithoutTime) === canonicalJson(rightWithoutTime);
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* A second collector may arrive after the first has enriched the terminal
|
|
2588
|
+
* receipt with transfer facts. It must converge to that durable receipt when
|
|
2589
|
+
* the owned turn and artifact identities are exact, while never accepting a
|
|
2590
|
+
* reordered or substituted artifact list.
|
|
2591
|
+
*/
|
|
2592
|
+
function sameTerminalReceiptIdentity(left, right) {
|
|
2593
|
+
const withoutArtifacts = (receipt) => {
|
|
2594
|
+
const { artifacts: _artifacts, completedAt: _completedAt, ...identity } = receipt;
|
|
2595
|
+
return identity;
|
|
2596
|
+
};
|
|
2597
|
+
if (canonicalJson(withoutArtifacts(left)) !== canonicalJson(withoutArtifacts(right)))
|
|
2598
|
+
return false;
|
|
2599
|
+
if (left.artifacts.length !== right.artifacts.length)
|
|
2600
|
+
return false;
|
|
2601
|
+
return left.artifacts.every((artifact, index) => {
|
|
2602
|
+
const expected = right.artifacts[index];
|
|
2603
|
+
return expected !== undefined
|
|
2604
|
+
&& artifact.schemaVersion === expected.schemaVersion
|
|
2605
|
+
&& artifact.operationId === expected.operationId
|
|
2606
|
+
&& artifact.artifactKey === expected.artifactKey
|
|
2607
|
+
&& artifact.assistantTurnId === expected.assistantTurnId
|
|
2608
|
+
&& artifact.sourceIdentityDigest === expected.sourceIdentityDigest
|
|
2609
|
+
&& artifact.kind === expected.kind
|
|
2610
|
+
&& artifact.ordinal === expected.ordinal
|
|
2611
|
+
&& artifact.mimeType === expected.mimeType
|
|
2612
|
+
&& (expected.bytes === undefined || artifact.bytes === expected.bytes)
|
|
2613
|
+
&& (expected.sha256 === undefined || artifact.sha256 === expected.sha256);
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2616
|
+
function controlReceiptFromAction(request, action, observedAt) {
|
|
2617
|
+
const receipt = {
|
|
2618
|
+
schemaVersion: "chatgpt.browser_control.operation_control_receipt.v1",
|
|
2619
|
+
controlActionId: request.controlActionId,
|
|
2620
|
+
parentOperationId: request.operationId,
|
|
2621
|
+
parentRequestDigest: request.parentRequestDigest,
|
|
2622
|
+
parentTargetBindingDigest: request.parentTargetBindingDigest,
|
|
2623
|
+
expectedAssistantTurnId: request.expectedAssistantTurnId,
|
|
2624
|
+
requestDigest: request.requestDigest,
|
|
2625
|
+
action: request.action,
|
|
2626
|
+
outcome: action.outcome,
|
|
2627
|
+
observedAt
|
|
2628
|
+
};
|
|
2629
|
+
if (action.evidenceDigest !== undefined)
|
|
2630
|
+
receipt.evidenceDigest = action.evidenceDigest;
|
|
2631
|
+
if (action.blockerCode !== undefined)
|
|
2632
|
+
receipt.blockerCode = action.blockerCode;
|
|
2633
|
+
return receipt;
|
|
2634
|
+
}
|
|
2635
|
+
function validateSteerVerificationReceipt(value, prepared, genericReceipt) {
|
|
2636
|
+
const record = readPlainServiceDataRecord(value);
|
|
2637
|
+
if (record === undefined) {
|
|
2638
|
+
throw new OperationServiceError("operation_state_corrupt", "Work-steer verification receipt is not a plain data record.");
|
|
2639
|
+
}
|
|
2640
|
+
const required = [
|
|
2641
|
+
"schemaVersion",
|
|
2642
|
+
"baselineSnapshotDigest",
|
|
2643
|
+
"preparedDigest",
|
|
2644
|
+
"assistantTurnId",
|
|
2645
|
+
"assistantBranchId",
|
|
2646
|
+
"assistantParentTurnId",
|
|
2647
|
+
"userTurnId",
|
|
2648
|
+
"userTurnEvidenceDigest",
|
|
2649
|
+
"postSendDeltaDigest",
|
|
2650
|
+
"evidenceDigest"
|
|
2651
|
+
];
|
|
2652
|
+
const keys = Object.keys(record).sort();
|
|
2653
|
+
if (keys.length !== required.length || keys.some((key, index) => key !== required.slice().sort()[index])) {
|
|
2654
|
+
throw new OperationServiceError("operation_state_corrupt", "Work-steer verification receipt contains unsupported fields.");
|
|
2655
|
+
}
|
|
2656
|
+
if (record.schemaVersion !== CONTROL_COORDINATOR_SCHEMA_VERSION
|
|
2657
|
+
|| record.baselineSnapshotDigest !== prepared.baselineSnapshotDigest
|
|
2658
|
+
|| record.preparedDigest !== prepared.preparedDigest
|
|
2659
|
+
|| record.assistantTurnId !== prepared.expectedAssistantTurnId
|
|
2660
|
+
|| record.assistantBranchId !== prepared.assistantBranchId
|
|
2661
|
+
|| record.assistantParentTurnId !== prepared.assistantParentTurnId
|
|
2662
|
+
|| record.evidenceDigest !== genericReceipt.evidenceDigest
|
|
2663
|
+
|| typeof record.userTurnId !== "string"
|
|
2664
|
+
|| !isSafeOpaqueId(record.userTurnId)
|
|
2665
|
+
|| typeof record.baselineSnapshotDigest !== "string"
|
|
2666
|
+
|| typeof record.preparedDigest !== "string"
|
|
2667
|
+
|| typeof record.userTurnEvidenceDigest !== "string"
|
|
2668
|
+
|| typeof record.postSendDeltaDigest !== "string"
|
|
2669
|
+
|| typeof record.evidenceDigest !== "string"
|
|
2670
|
+
|| !DIGEST_PATTERN.test(record.baselineSnapshotDigest)
|
|
2671
|
+
|| !DIGEST_PATTERN.test(record.preparedDigest)
|
|
2672
|
+
|| !DIGEST_PATTERN.test(record.userTurnEvidenceDigest)
|
|
2673
|
+
|| !DIGEST_PATTERN.test(record.postSendDeltaDigest)
|
|
2674
|
+
|| !DIGEST_PATTERN.test(record.evidenceDigest)) {
|
|
2675
|
+
throw new OperationServiceError("operation_request_mismatch", "Work-steer verification receipt is not bound to the durable prepared action.");
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
function stagingKinds(request) {
|
|
2679
|
+
const kinds = [];
|
|
2680
|
+
const configuration = request.configuration;
|
|
2681
|
+
if (configuration !== undefined) {
|
|
2682
|
+
if (configurationHasMenuValues(configuration, request.surface)) {
|
|
2683
|
+
kinds.push("configuration_set");
|
|
2684
|
+
}
|
|
2685
|
+
if (configuration.tools !== undefined)
|
|
2686
|
+
kinds.push("tool_set");
|
|
2687
|
+
if (configurationPowerValues(configuration, request.surface).length > 0)
|
|
2688
|
+
kinds.push("power_select");
|
|
2689
|
+
}
|
|
2690
|
+
kinds.push("composer_set");
|
|
2691
|
+
return kinds;
|
|
2692
|
+
}
|
|
2693
|
+
function stagingReceiptFromAction(identity, action) {
|
|
2694
|
+
if (action.outcome === undefined || action.receiptAt === undefined) {
|
|
2695
|
+
throw new OperationServiceError("operation_state_corrupt", "Settled staging action is incomplete.");
|
|
2696
|
+
}
|
|
2697
|
+
if (action.outcome !== "uncertain" && action.evidenceDigest === undefined) {
|
|
2698
|
+
throw new OperationServiceError("operation_state_corrupt", "Settled staging action is missing current-state evidence.");
|
|
2699
|
+
}
|
|
2700
|
+
return {
|
|
2701
|
+
schemaVersion: "chatgpt.browser_control.operation_staging_receipt.v1",
|
|
2702
|
+
operationId: identity.operationId,
|
|
2703
|
+
requestDigest: identity.requestDigest,
|
|
2704
|
+
targetBindingDigest: identity.targetBindingDigest,
|
|
2705
|
+
actionId: identity.actionId,
|
|
2706
|
+
kind: identity.kind,
|
|
2707
|
+
desiredStateDigest: identity.desiredStateDigest,
|
|
2708
|
+
outcome: action.outcome,
|
|
2709
|
+
mutation: "attempted",
|
|
2710
|
+
...(action.evidenceDigest === undefined ? {} : { currentStateDigest: action.evidenceDigest }),
|
|
2711
|
+
...(action.evidenceDigest === undefined ? {} : { evidenceDigest: action.evidenceDigest }),
|
|
2712
|
+
...(action.blockerCode === undefined ? {} : { blockerCode: action.blockerCode }),
|
|
2713
|
+
observedAt: action.receiptAt
|
|
2714
|
+
};
|
|
2715
|
+
}
|
|
2716
|
+
function stagingBlockerCode(kind, code) {
|
|
2717
|
+
if (code === "operation_cancelled" || code === "operation_timeout" || code === "journal_unavailable" || code === "target_binding_mismatch" || code === "target_evidence_unavailable" || code === "port_protocol_violation") {
|
|
2718
|
+
return code;
|
|
2719
|
+
}
|
|
2720
|
+
if (kind === "composer_set")
|
|
2721
|
+
return "composer_drift";
|
|
2722
|
+
return "configuration_drift";
|
|
2723
|
+
}
|
|
2724
|
+
function findArtifactTransfer(state, assistantTurnId, artifact) {
|
|
2725
|
+
const matches = Object.values(state.artifactTransfers ?? {}).filter(transfer => {
|
|
2726
|
+
const intent = transfer.intent;
|
|
2727
|
+
return intent !== undefined
|
|
2728
|
+
&& intent.operationId === artifact.operationId
|
|
2729
|
+
&& intent.assistantTurnId === assistantTurnId
|
|
2730
|
+
&& intent.sourceIdentityDigest === artifact.sourceIdentityDigest
|
|
2731
|
+
&& intent.kind === artifact.kind
|
|
2732
|
+
&& intent.ordinal === artifact.ordinal;
|
|
2733
|
+
});
|
|
2734
|
+
if (matches.length === 0)
|
|
2735
|
+
return undefined;
|
|
2736
|
+
// A completed receipt wins over a stale intent if a legacy/crash prefix
|
|
2737
|
+
// somehow contains both identities. More than one settled receipt for the
|
|
2738
|
+
// same terminal artifact is not safely projectable.
|
|
2739
|
+
const receipts = matches.filter(transfer => transfer.receipt !== undefined);
|
|
2740
|
+
if (receipts.length > 1) {
|
|
2741
|
+
const first = receipts[0].receipt;
|
|
2742
|
+
if (receipts.some(candidate => canonicalJson(candidate.receipt) !== canonicalJson(first))) {
|
|
2743
|
+
throw new OperationServiceError("operation_state_corrupt", "Multiple artifact transfer receipts conflict for one terminal artifact.");
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
return receipts[0] ?? matches[0];
|
|
2747
|
+
}
|
|
2748
|
+
function artifactTransferIdentityMatches(intent, lookup) {
|
|
2749
|
+
return intent.operationId === lookup.operationId
|
|
2750
|
+
&& intent.requestDigest === lookup.requestDigest
|
|
2751
|
+
&& intent.targetBindingDigest === lookup.targetBindingDigest
|
|
2752
|
+
&& intent.assistantTurnId === lookup.assistantTurnId
|
|
2753
|
+
&& intent.sourceIdentityDigest === lookup.sourceIdentityDigest
|
|
2754
|
+
&& intent.kind === lookup.kind
|
|
2755
|
+
&& intent.ordinal === lookup.ordinal
|
|
2756
|
+
&& intent.transferActionId === lookup.transferActionId
|
|
2757
|
+
&& intent.destinationIdentityDigest === lookup.destinationIdentityDigest;
|
|
2758
|
+
}
|
|
2759
|
+
function sameArtifactTransferIdentity(left, right) {
|
|
2760
|
+
return left.operationId === right.operationId
|
|
2761
|
+
&& left.requestDigest === right.requestDigest
|
|
2762
|
+
&& left.targetBindingDigest === right.targetBindingDigest
|
|
2763
|
+
&& left.assistantTurnId === right.assistantTurnId
|
|
2764
|
+
&& left.sourceIdentityDigest === right.sourceIdentityDigest
|
|
2765
|
+
&& left.kind === right.kind
|
|
2766
|
+
&& left.ordinal === right.ordinal
|
|
2767
|
+
&& left.transferActionId === right.transferActionId;
|
|
2768
|
+
}
|
|
2769
|
+
function artifactTransferFlightKey(identity) {
|
|
2770
|
+
return [
|
|
2771
|
+
identity.operationId,
|
|
2772
|
+
identity.requestDigest,
|
|
2773
|
+
identity.assistantTurnId,
|
|
2774
|
+
identity.sourceIdentityDigest,
|
|
2775
|
+
identity.kind,
|
|
2776
|
+
String(identity.ordinal)
|
|
2777
|
+
].join("\0");
|
|
2778
|
+
}
|
|
2779
|
+
function projectArtifactTransferReceipt(observed, transfer) {
|
|
2780
|
+
if (transfer.operationId !== observed.operationId
|
|
2781
|
+
|| transfer.assistantTurnId !== observed.assistantTurnId
|
|
2782
|
+
|| transfer.sourceIdentityDigest !== observed.sourceIdentityDigest
|
|
2783
|
+
|| transfer.kind !== observed.kind
|
|
2784
|
+
|| transfer.ordinal !== observed.ordinal) {
|
|
2785
|
+
throw new OperationServiceError("artifact_transfer_protocol_violation", "Artifact transfer receipt identity does not match the terminal artifact.");
|
|
2786
|
+
}
|
|
2787
|
+
if (observed.bytes !== undefined && transfer.bytes !== undefined && observed.bytes !== transfer.bytes) {
|
|
2788
|
+
throw new OperationServiceError("artifact_transfer_protocol_violation", "Artifact transfer changed an observed artifact byte count.");
|
|
2789
|
+
}
|
|
2790
|
+
if (observed.sha256 !== undefined && transfer.sha256 !== undefined && observed.sha256 !== transfer.sha256) {
|
|
2791
|
+
throw new OperationServiceError("artifact_transfer_protocol_violation", "Artifact transfer changed an observed artifact digest.");
|
|
2792
|
+
}
|
|
2793
|
+
return {
|
|
2794
|
+
...observed,
|
|
2795
|
+
...(transfer.outputKey === undefined ? {} : { outputKey: transfer.outputKey }),
|
|
2796
|
+
...(transfer.bytes === undefined ? {} : { bytes: transfer.bytes }),
|
|
2797
|
+
...(transfer.sha256 === undefined ? {} : { sha256: transfer.sha256 }),
|
|
2798
|
+
status: transfer.status,
|
|
2799
|
+
...(transfer.blockerCode === undefined ? {} : { blockerCode: transfer.blockerCode })
|
|
2800
|
+
};
|
|
2801
|
+
}
|
|
2802
|
+
function assertTerminalArtifactProjection(projected, observed) {
|
|
2803
|
+
if (projected.artifacts.length !== observed.artifacts.length) {
|
|
2804
|
+
throw new OperationServiceError("artifact_transfer_protocol_violation", "Artifact transfer changed the terminal artifact count.");
|
|
2805
|
+
}
|
|
2806
|
+
for (let index = 0; index < observed.artifacts.length; index += 1) {
|
|
2807
|
+
const expected = observed.artifacts[index];
|
|
2808
|
+
const actual = projected.artifacts[index];
|
|
2809
|
+
if (expected === undefined || actual === undefined) {
|
|
2810
|
+
throw new OperationServiceError("artifact_transfer_protocol_violation", "Artifact transfer changed terminal artifact ordering.");
|
|
2811
|
+
}
|
|
2812
|
+
if (expected.artifactKey !== actual.artifactKey
|
|
2813
|
+
|| expected.operationId !== actual.operationId
|
|
2814
|
+
|| expected.assistantTurnId !== actual.assistantTurnId
|
|
2815
|
+
|| expected.sourceIdentityDigest !== actual.sourceIdentityDigest
|
|
2816
|
+
|| expected.kind !== actual.kind
|
|
2817
|
+
|| expected.ordinal !== actual.ordinal
|
|
2818
|
+
|| expected.mimeType !== actual.mimeType) {
|
|
2819
|
+
throw new OperationServiceError("artifact_transfer_protocol_violation", "Artifact transfer changed terminal artifact identity.");
|
|
2820
|
+
}
|
|
2821
|
+
if (actual.status === "available") {
|
|
2822
|
+
throw new OperationServiceError("artifact_transfer_protocol_violation", "Transfer capture completed with an unsettled artifact.");
|
|
2823
|
+
}
|
|
2824
|
+
if ((actual.status === "partial" || actual.status === "blocked") && actual.blockerCode === undefined) {
|
|
2825
|
+
throw new OperationServiceError("artifact_transfer_protocol_violation", "Incomplete artifact transfer is missing a blocker.");
|
|
2826
|
+
}
|
|
2827
|
+
if (actual.status === "transferred" && (actual.outputKey === undefined || actual.bytes === undefined || actual.sha256 === undefined || actual.blockerCode !== undefined)) {
|
|
2828
|
+
throw new OperationServiceError("artifact_transfer_protocol_violation", "Transferred artifact receipt is incomplete.");
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
}
|