@margay/ccl-core 1.5.0-alpha.4 → 1.5.1-alpha.2
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/dist/cli.js +69586 -64065
- package/dist/sdk/background.d.ts +48 -0
- package/dist/sdk/background.js +87 -0
- package/dist/sdk/backgroundRunTypes.d.ts +101 -0
- package/dist/sdk/backgroundRunTypes.js +1 -0
- package/dist/sdk/commandAdapter.d.ts +77 -0
- package/dist/sdk/commandAdapter.js +180 -0
- package/dist/sdk/commandProcess.d.ts +27 -0
- package/dist/sdk/commandProcess.js +130 -0
- package/dist/sdk/executionGuidance.d.ts +7 -0
- package/dist/sdk/executionGuidance.js +37 -0
- package/dist/sdk/index.d.ts +13 -0
- package/dist/sdk/index.js +9 -0
- package/dist/sdk/mcpServer.d.ts +9 -5
- package/dist/sdk/mcpServer.js +51 -9
- package/dist/sdk/operations/command.d.ts +24 -0
- package/dist/sdk/operations/command.js +48 -0
- package/dist/sdk/operations/host.d.ts +3 -0
- package/dist/sdk/operations/host.js +376 -0
- package/dist/sdk/operations/json.d.ts +6 -0
- package/dist/sdk/operations/json.js +24 -0
- package/dist/sdk/operations/reference.d.ts +15 -0
- package/dist/sdk/operations/reference.js +19 -0
- package/dist/sdk/operations/store.d.ts +26 -0
- package/dist/sdk/operations/store.js +300 -0
- package/dist/sdk/operations/types.d.ts +171 -0
- package/dist/sdk/operations/types.js +8 -0
- package/dist/sdk/pendingApprovalIdentity.d.ts +35 -0
- package/dist/sdk/pendingApprovalIdentity.js +27 -0
- package/dist/sdk/permissionDelegation.d.ts +19 -0
- package/dist/sdk/permissionDelegation.js +11 -0
- package/dist/sdk/query.d.ts +2 -1
- package/dist/sdk/query.js +133 -30
- package/dist/sdk/queryLifecycle.d.ts +19 -0
- package/dist/sdk/queryLifecycle.js +93 -0
- package/dist/sdk/queryLifecycleTypes.d.ts +160 -0
- package/dist/sdk/queryLifecycleTypes.js +47 -0
- package/dist/sdk/scheduledOccurrenceTypes.d.ts +103 -0
- package/dist/sdk/scheduledOccurrenceTypes.js +12 -0
- package/dist/sdk/types.d.ts +17 -2
- package/docs/background-runs.md +229 -0
- package/docs/command-tool-recovery.md +62 -0
- package/docs/dev-quickstart.md +29 -7
- package/docs/error-recovery-usage.md +18 -0
- package/docs/hook-input-provenance.md +77 -0
- package/docs/index.md +2 -0
- package/docs/mcp-tool-recovery.md +82 -0
- package/docs/releases/1.5.0-alpha.5.md +26 -0
- package/docs/releases/1.5.0-alpha.6.md +15 -0
- package/docs/releases/1.5.1-alpha.1.md +11 -0
- package/docs/releases/1.5.1-alpha.2.md +17 -0
- package/docs/releases/1.5.1-alpha.md +22 -0
- package/docs/runtime-resources.md +6 -3
- package/examples/background-runs/agents/background-reviewer.md +6 -0
- package/examples/background-runs/agents/background-worker.md +6 -0
- package/examples/background-runs/worker-reviewer.mjs +42 -0
- package/examples/command-recovery/durable-server.mjs +44 -0
- package/examples/command-recovery/receipt-command.mjs +40 -0
- package/package.json +16 -2
- package/sdk/API.md +146 -0
- package/sdk/README.md +6 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { OperationStore } from './store.js';
|
|
3
|
+
import { operationClone, operationDigest, operationFreeze } from './json.js';
|
|
4
|
+
import { OperationError } from './types.js';
|
|
5
|
+
const processStartedAt = Date.now() - Math.round(process.uptime() * 1000);
|
|
6
|
+
function context(state) {
|
|
7
|
+
const { operationId, requestKey, scope, input, inputDigest, createdAt, binding, capabilities, remoteHandle } = state;
|
|
8
|
+
return operationFreeze(operationClone({ operationId, requestKey, scope, input, inputDigest, createdAt, binding, capabilities, remoteHandle }));
|
|
9
|
+
}
|
|
10
|
+
function revision(state, expected) {
|
|
11
|
+
if (!Number.isSafeInteger(expected) || state.revision !== expected)
|
|
12
|
+
throw new OperationError('conflict', 'Operation revision changed; read the current operation');
|
|
13
|
+
}
|
|
14
|
+
function owns(state, owner) {
|
|
15
|
+
if (state.owner?.token !== owner.token || state.owner.pid !== owner.pid)
|
|
16
|
+
throw new OperationError('conflict', 'Operation execution owner changed');
|
|
17
|
+
}
|
|
18
|
+
/** Shared lifecycle core for SDK hosts and CCL's CLI/tool entry points. */
|
|
19
|
+
export function createOperationHost(options) {
|
|
20
|
+
const store = new OperationStore(options.storeDir, options.scope);
|
|
21
|
+
const timeoutMs = options.timeoutMs ?? 600_000;
|
|
22
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
|
|
23
|
+
throw new Error('Invalid operation timeout');
|
|
24
|
+
async function bounded(work, signal) {
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
let timer;
|
|
27
|
+
let abort = () => { };
|
|
28
|
+
const interrupted = new Promise((_, reject) => {
|
|
29
|
+
abort = () => { controller.abort(); reject(new OperationError('cancelled', 'Operation request cancelled')); };
|
|
30
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
31
|
+
if (signal?.aborted)
|
|
32
|
+
abort();
|
|
33
|
+
timer = setTimeout(() => { controller.abort(); reject(new OperationError('authorization_required', 'Operation authorization or identity check did not finish before its deadline')); }, timeoutMs);
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
if (controller.signal.aborted)
|
|
37
|
+
throw new OperationError('cancelled', 'Operation request cancelled');
|
|
38
|
+
return await Promise.race([work(controller.signal), interrupted]);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
signal?.removeEventListener('abort', abort);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function permission(state, action, signal) {
|
|
46
|
+
const decision = await bounded(s => options.authorize({ operation: context(state), action, signal: s }), signal);
|
|
47
|
+
if (!decision || !['allow', 'deny', 'ask'].includes(decision.behavior))
|
|
48
|
+
throw new OperationError('permission_denied', 'Invalid host permission decision');
|
|
49
|
+
return decision;
|
|
50
|
+
}
|
|
51
|
+
async function resolve(state, recovering, signal) {
|
|
52
|
+
const adapter = await bounded(() => options.resolveAdapter(state.adapterRef), signal);
|
|
53
|
+
if (!adapter || operationDigest(adapter.binding) !== operationDigest(state.binding) || operationDigest(adapter.capabilities) !== operationDigest(state.capabilities))
|
|
54
|
+
throw new OperationError('capability_changed', 'Operation adapter, target, version or capabilities changed');
|
|
55
|
+
if (recovering || adapter.verifyIdentity) {
|
|
56
|
+
if (!adapter.verifyIdentity)
|
|
57
|
+
throw new OperationError('capability_changed', 'Recovery requires verification of the current external target identity');
|
|
58
|
+
const identity = await bounded(s => adapter.verifyIdentity(context(state), s), signal);
|
|
59
|
+
if (!identity || identity.targetIdentity !== state.binding.targetIdentity || identity.adapterVersion !== state.binding.adapterVersion)
|
|
60
|
+
throw new OperationError('capability_changed', 'External target identity or adapter version could not be verified');
|
|
61
|
+
}
|
|
62
|
+
return adapter;
|
|
63
|
+
}
|
|
64
|
+
function eligible(state) {
|
|
65
|
+
if (state.capabilities.replay === 'read_only')
|
|
66
|
+
return;
|
|
67
|
+
const contract = state.capabilities.idempotency;
|
|
68
|
+
if (state.capabilities.replay !== 'idempotent' || !contract || !contract.scope || !Number.isFinite(contract.retentionMs) || contract.retentionMs <= 0 || Date.now() - state.createdAt >= contract.retentionMs)
|
|
69
|
+
throw new OperationError('unsupported', 'Unknown operation effects require an unexpired, verified replay contract');
|
|
70
|
+
}
|
|
71
|
+
function fingerprint(action) { return operationDigest({ action }); }
|
|
72
|
+
function duplicate(state, request, action) {
|
|
73
|
+
if (!request.actionId)
|
|
74
|
+
throw new OperationError('conflict', 'Recovery actionId must be nonempty');
|
|
75
|
+
const old = state.actions.find(a => a.actionId === request.actionId);
|
|
76
|
+
if (!old)
|
|
77
|
+
return false;
|
|
78
|
+
if (old.fingerprint !== fingerprint(action))
|
|
79
|
+
throw new OperationError('conflict', 'actionId is already bound to another recovery action');
|
|
80
|
+
return old.state !== 'waiting_permission';
|
|
81
|
+
}
|
|
82
|
+
function saveAction(state, request, action) {
|
|
83
|
+
if (!request || state.actions.some(a => a.actionId === request.actionId))
|
|
84
|
+
return;
|
|
85
|
+
state.actions.push({ actionId: request.actionId, fingerprint: fingerprint(action), action, state: 'accepted' });
|
|
86
|
+
}
|
|
87
|
+
async function dispatch(operationId, expectedRevision, kind, request, signal) {
|
|
88
|
+
// Taking this OS lock also allows recovery of a journal left by a crashed writer.
|
|
89
|
+
const release = store.acquire(operationId);
|
|
90
|
+
let delegatedRelease = false;
|
|
91
|
+
const owner = { pid: process.pid, processStartedAt, token: randomUUID() };
|
|
92
|
+
let state;
|
|
93
|
+
try {
|
|
94
|
+
state = store.read(operationId);
|
|
95
|
+
await permissionOrThrowRead(state, signal);
|
|
96
|
+
if (request && duplicate(state, request, kind === 'query' ? 'reconcile' : 'retry'))
|
|
97
|
+
return state;
|
|
98
|
+
if (state.effect === 'confirmed' || (kind !== 'query' && state.execution === 'succeeded' && state.resultRef))
|
|
99
|
+
return state;
|
|
100
|
+
revision(state, expectedRevision);
|
|
101
|
+
if (kind === 'execute' && (state.effect !== 'not_dispatched' || !['accepted', 'waiting_permission', 'failed', 'interrupted'].includes(state.execution)))
|
|
102
|
+
throw new OperationError('conflict', 'Use recovery for an operation that may already have been dispatched');
|
|
103
|
+
if (kind === 'retry')
|
|
104
|
+
eligible(state);
|
|
105
|
+
const recovering = request !== undefined || kind !== 'execute' || state.attempts.length > 0 || state.owner !== undefined;
|
|
106
|
+
const decision = await permission(state, kind === 'query' ? 'query' : kind, signal);
|
|
107
|
+
if (decision.behavior !== 'allow') {
|
|
108
|
+
return store.update(operationId, 'permission_required', current => {
|
|
109
|
+
revision(current, expectedRevision);
|
|
110
|
+
if (request)
|
|
111
|
+
saveAction(current, request, kind === 'query' ? 'reconcile' : 'retry');
|
|
112
|
+
current.execution = 'waiting_permission';
|
|
113
|
+
current.reason = decision.reason ?? (decision.behavior === 'deny' ? 'Permission denied' : 'Current permission approval is required');
|
|
114
|
+
const action = current.actions.find(a => a.actionId === request?.actionId);
|
|
115
|
+
if (action)
|
|
116
|
+
action.state = 'waiting_permission';
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (kind === 'query' && !state.capabilities.query)
|
|
120
|
+
throw new OperationError('unsupported', 'No trusted query adapter is configured for this operation');
|
|
121
|
+
let adapter;
|
|
122
|
+
try {
|
|
123
|
+
adapter = await resolve(state, recovering, signal);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (!(error instanceof OperationError) || !['authorization_required', 'permission_denied'].includes(error.code))
|
|
127
|
+
throw error;
|
|
128
|
+
// Current credentials and identity-query permissions are part of the
|
|
129
|
+
// same recovery request, not a reason to lose its action identity.
|
|
130
|
+
return store.update(operationId, 'permission_required', current => {
|
|
131
|
+
revision(current, expectedRevision);
|
|
132
|
+
if (request)
|
|
133
|
+
saveAction(current, request, kind === 'query' ? 'reconcile' : 'retry');
|
|
134
|
+
current.execution = 'waiting_permission';
|
|
135
|
+
current.reason = error.message;
|
|
136
|
+
const action = current.actions.find(a => a.actionId === request?.actionId);
|
|
137
|
+
if (action)
|
|
138
|
+
action.state = 'waiting_permission';
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (kind === 'query' && (!state.capabilities.query || !adapter.queryOutcome))
|
|
142
|
+
throw new OperationError('unsupported', 'No trusted query adapter is configured for this operation');
|
|
143
|
+
if (kind === 'retry')
|
|
144
|
+
eligible(state);
|
|
145
|
+
if (signal?.aborted)
|
|
146
|
+
throw new OperationError('cancelled', 'Operation cancelled before dispatch');
|
|
147
|
+
const previouslyDispatched = state.effect !== 'not_dispatched';
|
|
148
|
+
const attemptId = randomUUID();
|
|
149
|
+
state = store.update(operationId, kind === 'query' ? 'query_dispatching' : 'dispatching', current => {
|
|
150
|
+
revision(current, expectedRevision);
|
|
151
|
+
if (current.cancellation !== 'none' && kind !== 'query')
|
|
152
|
+
throw new OperationError('cancelled', 'Cancelled operation cannot be dispatched');
|
|
153
|
+
saveAction(current, request, kind === 'query' ? 'reconcile' : 'retry');
|
|
154
|
+
// A previous owner is gone (the OS lock was acquired), but its remote work may continue.
|
|
155
|
+
for (const attempt of current.attempts)
|
|
156
|
+
if (attempt.status === 'dispatching') {
|
|
157
|
+
attempt.status = 'unknown';
|
|
158
|
+
attempt.endedAt = Date.now();
|
|
159
|
+
}
|
|
160
|
+
current.owner = owner;
|
|
161
|
+
current.attempts.push({ attemptId, kind, startedAt: Date.now(), status: 'dispatching', owner });
|
|
162
|
+
if (kind !== 'query') {
|
|
163
|
+
current.execution = 'dispatching';
|
|
164
|
+
current.effect = 'unknown';
|
|
165
|
+
}
|
|
166
|
+
else if (current.execution === 'dispatching' || current.execution === 'running')
|
|
167
|
+
current.execution = 'interrupted';
|
|
168
|
+
const action = current.actions.find(a => a.actionId === request?.actionId);
|
|
169
|
+
if (action)
|
|
170
|
+
action.state = 'accepted';
|
|
171
|
+
delete current.reason;
|
|
172
|
+
});
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
let timer, poll;
|
|
175
|
+
let returnedEarly = false;
|
|
176
|
+
let stopCaller;
|
|
177
|
+
let failCaller;
|
|
178
|
+
const early = new Promise((resolve, reject) => { stopCaller = resolve; failCaller = reject; });
|
|
179
|
+
const interrupt = (reason, cancellation) => {
|
|
180
|
+
if (returnedEarly)
|
|
181
|
+
return;
|
|
182
|
+
returnedEarly = true;
|
|
183
|
+
controller.abort();
|
|
184
|
+
try {
|
|
185
|
+
stopCaller(store.update(operationId, cancellation ? 'cancel_requested' : 'executor_timeout', current => {
|
|
186
|
+
owns(current, owner);
|
|
187
|
+
if (current.effect === 'confirmed')
|
|
188
|
+
return;
|
|
189
|
+
if (cancellation)
|
|
190
|
+
current.cancellation = 'requested';
|
|
191
|
+
if (kind !== 'query')
|
|
192
|
+
current.execution = 'interrupted';
|
|
193
|
+
current.reason = reason;
|
|
194
|
+
const action = current.actions.find(a => a.actionId === request?.actionId);
|
|
195
|
+
if (action)
|
|
196
|
+
action.state = 'unknown';
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
// Do not manufacture a successful cancellation when its journal commit failed.
|
|
201
|
+
// The invocation still owns its lock until the actual callback terminates.
|
|
202
|
+
failCaller(error);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const abort = () => interrupt('Caller requested cancellation; remote effects may remain unknown', true);
|
|
206
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
207
|
+
if (signal?.aborted)
|
|
208
|
+
abort();
|
|
209
|
+
poll = setInterval(() => {
|
|
210
|
+
try {
|
|
211
|
+
if (store.read(operationId).cancellation === 'requested')
|
|
212
|
+
interrupt('Cancellation requested; remote effects may remain unknown', true);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
interrupt('Operation state could not be read; execution cancellation requested', false);
|
|
216
|
+
}
|
|
217
|
+
}, 100);
|
|
218
|
+
timer = setTimeout(() => interrupt('Executor did not return before its deadline; the original execution may still be active', false), timeoutMs);
|
|
219
|
+
const cleanup = () => { clearTimeout(timer); clearInterval(poll); signal?.removeEventListener('abort', abort); release(); };
|
|
220
|
+
let invocation;
|
|
221
|
+
try {
|
|
222
|
+
invocation = (kind === 'query' ? adapter.queryOutcome : adapter.execute).call(adapter, {
|
|
223
|
+
operation: context(state), attemptId, signal: controller.signal, recovering, previouslyDispatched,
|
|
224
|
+
recordDispatch: dispatchId => {
|
|
225
|
+
if (controller.signal.aborted)
|
|
226
|
+
throw new OperationError('cancelled', 'Cancelled before transport dispatch');
|
|
227
|
+
store.update(operationId, 'transport_dispatching', current => {
|
|
228
|
+
owns(current, owner);
|
|
229
|
+
if (current.cancellation !== 'none')
|
|
230
|
+
throw new OperationError('cancelled', 'Cancellation requested before transport dispatch');
|
|
231
|
+
const attempt = current.attempts.find(a => a.attemptId === attemptId);
|
|
232
|
+
const dispatches = attempt.dispatches ??= [];
|
|
233
|
+
if (dispatches.some(d => d.dispatchId === dispatchId))
|
|
234
|
+
throw new OperationError('conflict', 'Transport dispatch identity already used');
|
|
235
|
+
dispatches.push({ dispatchId, at: Date.now() });
|
|
236
|
+
});
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
invocation = Promise.reject(error);
|
|
242
|
+
}
|
|
243
|
+
const completion = invocation.then(result => store.update(operationId, 'attempt_returned', (current, put) => {
|
|
244
|
+
owns(current, owner);
|
|
245
|
+
const attempt = current.attempts.find(a => a.attemptId === attemptId);
|
|
246
|
+
attempt.status = 'returned';
|
|
247
|
+
attempt.endedAt = Date.now();
|
|
248
|
+
if (current.effect !== 'confirmed') {
|
|
249
|
+
if (result.outcome.status === 'in_progress') {
|
|
250
|
+
current.remoteHandle = result.outcome.handle;
|
|
251
|
+
current.execution = 'running';
|
|
252
|
+
}
|
|
253
|
+
if (result.outcome.status === 'applied') {
|
|
254
|
+
current.effect = 'confirmed';
|
|
255
|
+
current.execution = 'succeeded';
|
|
256
|
+
current.receiptRef = put(result.outcome.receipt);
|
|
257
|
+
current.resultRef = put(result.outcome.result ?? result.result ?? null);
|
|
258
|
+
}
|
|
259
|
+
else if (kind !== 'query') {
|
|
260
|
+
current.resultRef = put(result.result ?? { outcome: result.outcome, process: result.process ?? null });
|
|
261
|
+
if (result.dispatch === 'not_dispatched' && !previouslyDispatched)
|
|
262
|
+
current.effect = 'not_dispatched';
|
|
263
|
+
current.execution = result.execution ?? (current.cancellation === 'requested' ? 'cancelled' : result.outcome.status === 'in_progress' ? 'running' : 'failed');
|
|
264
|
+
}
|
|
265
|
+
if (result.outcome.status === 'unknown' || result.outcome.status === 'unsupported')
|
|
266
|
+
current.reason = result.outcome.reason;
|
|
267
|
+
else
|
|
268
|
+
delete current.reason;
|
|
269
|
+
}
|
|
270
|
+
delete current.owner;
|
|
271
|
+
const action = current.actions.find(a => a.actionId === request?.actionId);
|
|
272
|
+
if (action) {
|
|
273
|
+
action.state = 'finished';
|
|
274
|
+
action.resultRevision = current.revision + 1;
|
|
275
|
+
}
|
|
276
|
+
}), error => store.update(operationId, 'attempt_failed', current => {
|
|
277
|
+
owns(current, owner);
|
|
278
|
+
const attempt = current.attempts.find(a => a.attemptId === attemptId);
|
|
279
|
+
attempt.status = 'unknown';
|
|
280
|
+
attempt.endedAt = Date.now();
|
|
281
|
+
if (current.effect !== 'confirmed' && kind !== 'query')
|
|
282
|
+
current.execution = 'interrupted';
|
|
283
|
+
current.reason = error instanceof Error ? error.message : 'Adapter invocation failed; effects remain unknown';
|
|
284
|
+
delete current.owner;
|
|
285
|
+
const action = current.actions.find(a => a.actionId === request?.actionId);
|
|
286
|
+
if (action) {
|
|
287
|
+
action.state = 'unknown';
|
|
288
|
+
action.resultRevision = current.revision + 1;
|
|
289
|
+
}
|
|
290
|
+
})).finally(cleanup);
|
|
291
|
+
// A timed-out adapter can finish late. Do not release its OS lock merely because
|
|
292
|
+
// the caller's wait ended; completion owns cleanup and records any late receipt.
|
|
293
|
+
delegatedRelease = true;
|
|
294
|
+
return await Promise.race([completion, early]);
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
if (!delegatedRelease)
|
|
298
|
+
release();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function permissionOrThrowRead(state, signal) {
|
|
302
|
+
const p = await permission(state, 'read', signal);
|
|
303
|
+
if (p.behavior !== 'allow')
|
|
304
|
+
throw new OperationError(p.behavior === 'ask' ? 'authorization_required' : 'permission_denied', p.reason ?? 'Current permission is required to access this operation');
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
async submitOperation(input) {
|
|
308
|
+
const adapter = await bounded(() => options.resolveAdapter(input.adapterRef));
|
|
309
|
+
if (!adapter)
|
|
310
|
+
throw new OperationError('unsupported', 'Operation adapter is not registered by the host');
|
|
311
|
+
const now = Date.now(), value = operationClone(input.input);
|
|
312
|
+
const initial = { operationId: store.idFor(input.requestKey), requestKey: input.requestKey, scope: options.scope,
|
|
313
|
+
input: value, inputDigest: operationDigest(value), createdAt: now, binding: operationClone(adapter.binding), capabilities: operationClone(adapter.capabilities),
|
|
314
|
+
adapterRef: input.adapterRef, execution: 'accepted', effect: 'not_dispatched', cancellation: 'none',
|
|
315
|
+
links: input.links ?? {}, attempts: [], actions: [], updatedAt: now };
|
|
316
|
+
await permissionOrThrowRead(initial);
|
|
317
|
+
return store.create(initial);
|
|
318
|
+
},
|
|
319
|
+
executeOperation: input => dispatch(input.operationId, input.expectedRevision, 'execute', undefined, input.signal),
|
|
320
|
+
async getOperation(input) { const state = store.read(input.operationId); await permissionOrThrowRead(state); return state; },
|
|
321
|
+
async readOperationEvents(input) { const state = store.read(input.operationId); await permissionOrThrowRead(state); return store.events(input.operationId, input.cursor); },
|
|
322
|
+
reconcileOperation: input => dispatch(input.operationId, input.expectedRevision, 'query', input, input.signal),
|
|
323
|
+
async resumeOperation(input) {
|
|
324
|
+
if (input.action === 'retry') {
|
|
325
|
+
const release = store.acquire(input.operationId);
|
|
326
|
+
let state;
|
|
327
|
+
try {
|
|
328
|
+
state = store.read(input.operationId);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
release();
|
|
332
|
+
}
|
|
333
|
+
// Accepted work never crossed dispatch; it can continue without inventing an idempotency contract.
|
|
334
|
+
if (state.effect === 'not_dispatched')
|
|
335
|
+
return dispatch(input.operationId, input.expectedRevision, 'execute', input, input.signal);
|
|
336
|
+
return dispatch(input.operationId, input.expectedRevision, 'retry', input, input.signal);
|
|
337
|
+
}
|
|
338
|
+
const release = store.acquire(input.operationId);
|
|
339
|
+
try {
|
|
340
|
+
const state = store.read(input.operationId);
|
|
341
|
+
await permissionOrThrowRead(state, input.signal);
|
|
342
|
+
if (duplicate(state, input, 'accept_verified_result'))
|
|
343
|
+
return state;
|
|
344
|
+
revision(state, input.expectedRevision);
|
|
345
|
+
if (state.effect !== 'confirmed' || !state.receiptRef)
|
|
346
|
+
throw new OperationError('unsupported', 'No adapter-verified receipt exists for this operation');
|
|
347
|
+
return store.update(input.operationId, 'verified_result_accepted', current => {
|
|
348
|
+
revision(current, input.expectedRevision);
|
|
349
|
+
current.actions.push({ actionId: input.actionId, fingerprint: fingerprint('accept_verified_result'), action: 'accept_verified_result', state: 'finished', resultRevision: current.revision + 1 });
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
release();
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
async cancelOperation(input) {
|
|
357
|
+
const state = store.read(input.operationId);
|
|
358
|
+
await permissionOrThrowRead(state, input.signal);
|
|
359
|
+
if (duplicate(state, input, 'cancel'))
|
|
360
|
+
return state;
|
|
361
|
+
revision(state, input.expectedRevision);
|
|
362
|
+
const p = await permission(state, 'cancel', input.signal);
|
|
363
|
+
if (p.behavior !== 'allow')
|
|
364
|
+
throw new OperationError(p.behavior === 'ask' ? 'authorization_required' : 'permission_denied', p.reason ?? 'Current cancellation permission is required');
|
|
365
|
+
return store.update(input.operationId, 'cancel_requested', current => {
|
|
366
|
+
revision(current, input.expectedRevision);
|
|
367
|
+
current.actions.push({ actionId: input.actionId, fingerprint: fingerprint('cancel'), action: 'cancel', state: 'finished', resultRevision: current.revision + 1 });
|
|
368
|
+
if (current.effect === 'confirmed' || current.execution === 'succeeded')
|
|
369
|
+
return;
|
|
370
|
+
current.cancellation = current.effect === 'not_dispatched' && !current.owner ? 'confirmed' : 'requested';
|
|
371
|
+
if (current.cancellation === 'confirmed')
|
|
372
|
+
current.execution = 'cancelled';
|
|
373
|
+
});
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { JsonValue } from '../commandAdapter.js';
|
|
2
|
+
export declare function operationJson(value: unknown): string;
|
|
3
|
+
export declare function operationDigest(value: unknown): string;
|
|
4
|
+
export declare function operationClone<T>(value: T): T;
|
|
5
|
+
export declare function operationFreeze<T>(value: T): T;
|
|
6
|
+
export declare function asOperationJson(value: unknown): JsonValue;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export function operationJson(value) {
|
|
3
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
4
|
+
return JSON.stringify(value);
|
|
5
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
6
|
+
return JSON.stringify(value);
|
|
7
|
+
if (Array.isArray(value))
|
|
8
|
+
return `[${value.map(operationJson).join(',')}]`;
|
|
9
|
+
if (value && typeof value === 'object' && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) {
|
|
10
|
+
return `{${Object.keys(value).filter(k => value[k] !== undefined).sort().map(k => `${JSON.stringify(k)}:${operationJson(value[k])}`).join(',')}}`;
|
|
11
|
+
}
|
|
12
|
+
throw new Error('Operation records require finite JSON values');
|
|
13
|
+
}
|
|
14
|
+
export function operationDigest(value) { return createHash('sha256').update(operationJson(value)).digest('hex'); }
|
|
15
|
+
export function operationClone(value) { return JSON.parse(operationJson(value)); }
|
|
16
|
+
export function operationFreeze(value) {
|
|
17
|
+
if (value && typeof value === 'object') {
|
|
18
|
+
for (const child of Object.values(value))
|
|
19
|
+
operationFreeze(child);
|
|
20
|
+
Object.freeze(value);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export function asOperationJson(value) { return JSON.parse(operationJson(value)); }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Host-issued lookup identity for one tool result; not evidence of business success. */
|
|
2
|
+
export type OperationReference = Readonly<{
|
|
3
|
+
operationId: string;
|
|
4
|
+
revision: number;
|
|
5
|
+
toolUseId: string;
|
|
6
|
+
}>;
|
|
7
|
+
/** Read only the dedicated host envelope, never MCP server metadata or tool content. */
|
|
8
|
+
export declare function readOperationReference(value: unknown, toolUseId: string): OperationReference | undefined;
|
|
9
|
+
export declare function operationReferenceNotice(ref: OperationReference): {
|
|
10
|
+
type: 'text';
|
|
11
|
+
text: string;
|
|
12
|
+
};
|
|
13
|
+
/** Match only our exact current/previous envelope, preserving historical UI
|
|
14
|
+
* classification when the guidance changes. Arbitrary tool text stays visible. */
|
|
15
|
+
export declare function isOperationReferenceNotice(text: string, ref: OperationReference): boolean;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Read only the dedicated host envelope, never MCP server metadata or tool content. */
|
|
2
|
+
export function readOperationReference(value, toolUseId) {
|
|
3
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
4
|
+
return undefined;
|
|
5
|
+
const ref = value;
|
|
6
|
+
if (typeof ref.operationId !== 'string' || !/^op_[a-f0-9]{64}$/.test(ref.operationId)
|
|
7
|
+
|| !Number.isSafeInteger(ref.revision) || ref.revision < 1
|
|
8
|
+
|| ref.toolUseId !== toolUseId || !toolUseId)
|
|
9
|
+
return undefined;
|
|
10
|
+
return { operationId: ref.operationId, revision: ref.revision, toolUseId };
|
|
11
|
+
}
|
|
12
|
+
export function operationReferenceNotice(ref) {
|
|
13
|
+
return { type: 'text', text: `Host recovery reference for tool use ${ref.toolUseId}: ${ref.operationId} (revision ${ref.revision}). The accompanying tool_result contains the original return. Continue the requested work using that return; query Operation get only when recovery or a saved-state check is needed, especially before replacing an uncertain invocation. A business value in the return is not automatically a host run ID. Saved state records past facts and does not prove current remote liveness or business completion.` };
|
|
14
|
+
}
|
|
15
|
+
/** Match only our exact current/previous envelope, preserving historical UI
|
|
16
|
+
* classification when the guidance changes. Arbitrary tool text stays visible. */
|
|
17
|
+
export function isOperationReferenceNotice(text, ref) {
|
|
18
|
+
return text === operationReferenceNotice(ref).text || text === `Host operation for tool use ${ref.toolUseId}: ${ref.operationId} (revision ${ref.revision}). Use Operation get to inspect its saved state/result. Saved state is the last recorded fact, not proof of current remote liveness or completion. This reference does not confirm business effects; inspect it before starting a replacement invocation.`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { JsonValue } from '../commandAdapter.js';
|
|
2
|
+
import { type OperationEvent, type OperationState, type OperationView } from './types.js';
|
|
3
|
+
/** One authoritative transaction journal per operation; the separate database is only an OS lock. */
|
|
4
|
+
export declare class OperationStore {
|
|
5
|
+
readonly storeDir: string;
|
|
6
|
+
readonly scope: string;
|
|
7
|
+
readonly root: string;
|
|
8
|
+
constructor(storeDir: string, scope: string);
|
|
9
|
+
idFor(requestKey: string): string;
|
|
10
|
+
private directory;
|
|
11
|
+
private open;
|
|
12
|
+
create(initial: Omit<OperationState, 'revision' | 'generation' | 'schemaVersion'>): OperationView;
|
|
13
|
+
private append;
|
|
14
|
+
private frames;
|
|
15
|
+
private result;
|
|
16
|
+
private view;
|
|
17
|
+
read(operationId: string): OperationView;
|
|
18
|
+
/** Mutator runs synchronously under the journal transaction; never awaits external work. */
|
|
19
|
+
update(operationId: string, event: string, mutate: (state: OperationState, putResult: (value: JsonValue) => string) => void): OperationView;
|
|
20
|
+
/** Only the lock holder may perform writable journal recovery after a crash. */
|
|
21
|
+
acquire(operationId: string): () => void;
|
|
22
|
+
events(operationId: string, cursor?: string): {
|
|
23
|
+
events: OperationEvent[];
|
|
24
|
+
nextCursor: string;
|
|
25
|
+
};
|
|
26
|
+
}
|