@victframework/runtime 0.1.0
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/agent-governance.d.ts +285 -0
- package/dist/agent-governance.js +438 -0
- package/dist/agent-governance.js.map +1 -0
- package/dist/agent-registry.d.ts +103 -0
- package/dist/agent-registry.js +758 -0
- package/dist/agent-registry.js.map +1 -0
- package/dist/agent-types.d.ts +433 -0
- package/dist/agent-types.js +480 -0
- package/dist/agent-types.js.map +1 -0
- package/dist/authority.d.ts +83 -0
- package/dist/authority.js +170 -0
- package/dist/authority.js.map +1 -0
- package/dist/boundary-conformance.d.ts +51 -0
- package/dist/boundary-conformance.js +364 -0
- package/dist/boundary-conformance.js.map +1 -0
- package/dist/control-conformance.d.ts +68 -0
- package/dist/control-conformance.js +1131 -0
- package/dist/control-conformance.js.map +1 -0
- package/dist/control-in-memory.d.ts +257 -0
- package/dist/control-in-memory.js +886 -0
- package/dist/control-in-memory.js.map +1 -0
- package/dist/control-types.d.ts +1017 -0
- package/dist/control-types.js +779 -0
- package/dist/control-types.js.map +1 -0
- package/dist/durable-run.d.ts +73 -0
- package/dist/durable-run.js +262 -0
- package/dist/durable-run.js.map +1 -0
- package/dist/effect-policy.d.ts +22 -0
- package/dist/effect-policy.js +45 -0
- package/dist/effect-policy.js.map +1 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +71 -0
- package/dist/errors.js.map +1 -0
- package/dist/in-memory-stores.d.ts +16 -0
- package/dist/in-memory-stores.js +522 -0
- package/dist/in-memory-stores.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/operator-config.d.ts +141 -0
- package/dist/operator-config.js +289 -0
- package/dist/operator-config.js.map +1 -0
- package/dist/orchestration-activation.d.ts +116 -0
- package/dist/orchestration-activation.js +223 -0
- package/dist/orchestration-activation.js.map +1 -0
- package/dist/orchestration-canary-conformance.d.ts +10 -0
- package/dist/orchestration-canary-conformance.js +726 -0
- package/dist/orchestration-canary-conformance.js.map +1 -0
- package/dist/orchestration-commands.d.ts +47 -0
- package/dist/orchestration-commands.js +538 -0
- package/dist/orchestration-commands.js.map +1 -0
- package/dist/orchestration-conformance.d.ts +59 -0
- package/dist/orchestration-conformance.js +594 -0
- package/dist/orchestration-conformance.js.map +1 -0
- package/dist/orchestration-driver-types.d.ts +163 -0
- package/dist/orchestration-driver-types.js +21 -0
- package/dist/orchestration-driver-types.js.map +1 -0
- package/dist/orchestration-driver.d.ts +48 -0
- package/dist/orchestration-driver.js +1030 -0
- package/dist/orchestration-driver.js.map +1 -0
- package/dist/orchestration-in-memory.d.ts +18 -0
- package/dist/orchestration-in-memory.js +1313 -0
- package/dist/orchestration-in-memory.js.map +1 -0
- package/dist/orchestration-join-conformance.d.ts +20 -0
- package/dist/orchestration-join-conformance.js +542 -0
- package/dist/orchestration-join-conformance.js.map +1 -0
- package/dist/orchestration-plan.d.ts +52 -0
- package/dist/orchestration-plan.js +265 -0
- package/dist/orchestration-plan.js.map +1 -0
- package/dist/orchestration-race-conformance.d.ts +35 -0
- package/dist/orchestration-race-conformance.js +1265 -0
- package/dist/orchestration-race-conformance.js.map +1 -0
- package/dist/orchestration-remediation-conformance.d.ts +54 -0
- package/dist/orchestration-remediation-conformance.js +632 -0
- package/dist/orchestration-remediation-conformance.js.map +1 -0
- package/dist/orchestration-store-types.d.ts +568 -0
- package/dist/orchestration-store-types.js +2 -0
- package/dist/orchestration-store-types.js.map +1 -0
- package/dist/pack-conformance.d.ts +59 -0
- package/dist/pack-conformance.js +222 -0
- package/dist/pack-conformance.js.map +1 -0
- package/dist/pack-install.d.ts +44 -0
- package/dist/pack-install.js +126 -0
- package/dist/pack-install.js.map +1 -0
- package/dist/registry.d.ts +107 -0
- package/dist/registry.js +481 -0
- package/dist/registry.js.map +1 -0
- package/dist/runtime.d.ts +158 -0
- package/dist/runtime.js +1341 -0
- package/dist/runtime.js.map +1 -0
- package/dist/serialization.d.ts +26 -0
- package/dist/serialization.js +162 -0
- package/dist/serialization.js.map +1 -0
- package/dist/store-conformance.d.ts +45 -0
- package/dist/store-conformance.js +876 -0
- package/dist/store-conformance.js.map +1 -0
- package/dist/store-errors.d.ts +49 -0
- package/dist/store-errors.js +48 -0
- package/dist/store-errors.js.map +1 -0
- package/dist/store-types.d.ts +331 -0
- package/dist/store-types.js +14 -0
- package/dist/store-types.js.map +1 -0
- package/dist/store-validation.d.ts +63 -0
- package/dist/store-validation.js +164 -0
- package/dist/store-validation.js.map +1 -0
- package/dist/stream-hub.d.ts +151 -0
- package/dist/stream-hub.js +402 -0
- package/dist/stream-hub.js.map +1 -0
- package/dist/testing.d.ts +25 -0
- package/dist/testing.js +19 -0
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +221 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { InMemoryActorDirectory, isDurableStreamKind } from './control-types.js';
|
|
3
|
+
export { InMemoryActorDirectory };
|
|
4
|
+
import { VictControlError, VICT_IDEMPOTENCY_FENCE_CONFLICT, CHANGESET_BASE_NONE, commandIdempotencyFenceToken, validateStreamLedgerAppend, } from './control-types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Stage 06B in-memory reference implementations of the neutral control
|
|
7
|
+
* plane and agent-execution store ports. The SQLite adapter implements the
|
|
8
|
+
* SAME semantics in `@victframework/store-sqlite`; the shared conformance suite
|
|
9
|
+
* (`control-conformance.ts`) proves the adapters cannot diverge.
|
|
10
|
+
*
|
|
11
|
+
* In-memory durability model: process-local maps guarded by a single
|
|
12
|
+
* synchronous critical section per mutation (compare-and-set semantics are
|
|
13
|
+
* enforced inside each mutation, so concurrent callers see one truthful
|
|
14
|
+
* winner).
|
|
15
|
+
*/
|
|
16
|
+
/** Deterministic test-stability digest (ids are derived, never random). */
|
|
17
|
+
function digest(payload) {
|
|
18
|
+
return createHash('sha256').update(payload, 'utf8').digest('hex').slice(0, 24);
|
|
19
|
+
}
|
|
20
|
+
/** In-memory ControlPlaneStore. */
|
|
21
|
+
export class InMemoryControlPlaneStore {
|
|
22
|
+
#changesets = new Map();
|
|
23
|
+
#approvals = new Map();
|
|
24
|
+
#approvalsByChangeset = new Map();
|
|
25
|
+
#releases = new Map();
|
|
26
|
+
#selections = new Map();
|
|
27
|
+
#audit = [];
|
|
28
|
+
#controlRuns = new Map();
|
|
29
|
+
#operationReceipts = new Map();
|
|
30
|
+
async saveChangeSet(record) {
|
|
31
|
+
if (this.#changesets.has(record.changesetId)) {
|
|
32
|
+
throw new VictControlError('VICT_CONTROL_CHANGESET_EXISTS', 'A ChangeSet with this id already exists; propose a distinct changesetId.');
|
|
33
|
+
}
|
|
34
|
+
this.#changesets.set(record.changesetId, { record: structuredCloneControl(record) });
|
|
35
|
+
}
|
|
36
|
+
async getChangeSet(changesetId) {
|
|
37
|
+
const found = this.#changesets.get(changesetId);
|
|
38
|
+
return found === undefined ? undefined : structuredCloneControl(found.record);
|
|
39
|
+
}
|
|
40
|
+
async listChangeSets() {
|
|
41
|
+
return [...this.#changesets.values()]
|
|
42
|
+
.map((entry) => entry.record)
|
|
43
|
+
.sort((a, b) => (a.changesetId < b.changesetId ? -1 : 1))
|
|
44
|
+
.map((record) => structuredCloneControl(record));
|
|
45
|
+
}
|
|
46
|
+
async updateChangeSet(changesetId, update) {
|
|
47
|
+
const entry = this.#changesets.get(changesetId);
|
|
48
|
+
if (entry === undefined) {
|
|
49
|
+
throw new VictControlError('VICT_CONTROL_CHANGESET_MISSING', 'The ChangeSet does not exist.');
|
|
50
|
+
}
|
|
51
|
+
const updated = update(structuredCloneControl(entry.record));
|
|
52
|
+
if (updated.changesetId !== changesetId) {
|
|
53
|
+
throw new VictControlError('VICT_CONTROL_CHANGESET_IMMUTABLE_ID', 'A ChangeSet update may not change its changesetId.');
|
|
54
|
+
}
|
|
55
|
+
if (updated.contentHash !== entry.record.contentHash) {
|
|
56
|
+
// Content mutation is a distinct service-level operation (revise) that
|
|
57
|
+
// must reset evidence/approvals; the raw store update enforces the
|
|
58
|
+
// content identity invariant.
|
|
59
|
+
throw new VictControlError('VICT_CONTROL_CONTENT_HASH_MISMATCH', 'A ChangeSet update may not change its immutable content identity.');
|
|
60
|
+
}
|
|
61
|
+
entry.record = structuredCloneControl(updated);
|
|
62
|
+
return structuredCloneControl(updated);
|
|
63
|
+
}
|
|
64
|
+
async reviseChangeSetContent(changesetId, revise) {
|
|
65
|
+
const entry = this.#changesets.get(changesetId);
|
|
66
|
+
if (entry === undefined) {
|
|
67
|
+
throw new VictControlError('VICT_CONTROL_CHANGESET_MISSING', 'The ChangeSet does not exist.');
|
|
68
|
+
}
|
|
69
|
+
const updated = revise(structuredCloneControl(entry.record));
|
|
70
|
+
if (updated.changesetId !== changesetId) {
|
|
71
|
+
throw new VictControlError('VICT_CONTROL_CHANGESET_IMMUTABLE_ID', 'A ChangeSet update may not change its changesetId.');
|
|
72
|
+
}
|
|
73
|
+
if (updated.contentHash === entry.record.contentHash) {
|
|
74
|
+
throw new VictControlError('VICT_CONTROL_REVISION_NOT_CHANGED', 'A content revision must change the immutable content identity.');
|
|
75
|
+
}
|
|
76
|
+
if (updated.validation !== undefined || updated.simulation !== undefined) {
|
|
77
|
+
throw new VictControlError('VICT_CONTROL_EVIDENCE_NOT_INVALIDATED', 'A content revision must reset validation and simulation evidence.');
|
|
78
|
+
}
|
|
79
|
+
entry.record = structuredCloneControl(updated);
|
|
80
|
+
return structuredCloneControl(updated);
|
|
81
|
+
}
|
|
82
|
+
async recordChangeSetApproval(decision) {
|
|
83
|
+
const key = `${decision.changesetId}\u0000${decision.approverActorId}`;
|
|
84
|
+
const existing = this.#approvals.get(key);
|
|
85
|
+
if (existing !== undefined) {
|
|
86
|
+
if (existing.decision !== decision.decision ||
|
|
87
|
+
existing.contentHash !== decision.contentHash) {
|
|
88
|
+
throw new VictControlError('VICT_CONTROL_APPROVAL_CONFLICT', 'A competing decision already exists for this ChangeSet and approver; the first durable decision stands.');
|
|
89
|
+
}
|
|
90
|
+
return; // idempotent identical decision
|
|
91
|
+
}
|
|
92
|
+
this.#approvals.set(key, structuredCloneControl(decision));
|
|
93
|
+
const list = this.#approvalsByChangeset.get(decision.changesetId) ?? [];
|
|
94
|
+
list.push(key);
|
|
95
|
+
this.#approvalsByChangeset.set(decision.changesetId, list);
|
|
96
|
+
}
|
|
97
|
+
async listChangeSetApprovals(changesetId) {
|
|
98
|
+
const keys = this.#approvalsByChangeset.get(changesetId) ?? [];
|
|
99
|
+
return keys
|
|
100
|
+
.map((key) => this.#approvals.get(key))
|
|
101
|
+
.filter((entry) => entry !== undefined)
|
|
102
|
+
.map((decision) => structuredCloneControl(decision));
|
|
103
|
+
}
|
|
104
|
+
async recordControlRun(record) {
|
|
105
|
+
const existing = this.#controlRuns.get(record.runId);
|
|
106
|
+
if (existing !== undefined) {
|
|
107
|
+
if (JSON.stringify(existing) !== JSON.stringify(record)) {
|
|
108
|
+
throw new VictControlError('VICT_CONTROL_RUN_COLLISION', 'A governance run with this id already exists with different content.');
|
|
109
|
+
}
|
|
110
|
+
return; // idempotent re-record
|
|
111
|
+
}
|
|
112
|
+
this.#controlRuns.set(record.runId, structuredCloneControl(record));
|
|
113
|
+
}
|
|
114
|
+
async getControlRun(runId) {
|
|
115
|
+
const found = this.#controlRuns.get(runId);
|
|
116
|
+
return found === undefined ? undefined : structuredCloneControl(found);
|
|
117
|
+
}
|
|
118
|
+
async recordOperationIntent(receipt) {
|
|
119
|
+
const key = `${receipt.changesetId}\u0000${receipt.operationIndex}`;
|
|
120
|
+
const existing = this.#operationReceipts.get(key);
|
|
121
|
+
if (existing !== undefined) {
|
|
122
|
+
if (existing.operationDigest !== receipt.operationDigest) {
|
|
123
|
+
throw new VictControlError('VICT_CONTROL_OPERATION_IDENTITY_CONFLICT', 'The operation identity already exists with different content; conflicting content fails closed.');
|
|
124
|
+
}
|
|
125
|
+
return 'exists'; // idempotent re-intent
|
|
126
|
+
}
|
|
127
|
+
this.#operationReceipts.set(key, structuredCloneControl(receipt));
|
|
128
|
+
return 'recorded';
|
|
129
|
+
}
|
|
130
|
+
async markOperationApplied(input) {
|
|
131
|
+
const key = `${input.changesetId}\u0000${input.operationIndex}`;
|
|
132
|
+
const existing = this.#operationReceipts.get(key);
|
|
133
|
+
if (existing === undefined) {
|
|
134
|
+
throw new VictControlError('VICT_CONTROL_OPERATION_RECEIPT_MISSING', 'No prepared operation intent exists for this operation index.');
|
|
135
|
+
}
|
|
136
|
+
if (existing.state === 'applied') {
|
|
137
|
+
return structuredCloneControl(existing); // idempotent
|
|
138
|
+
}
|
|
139
|
+
const updated = {
|
|
140
|
+
...existing,
|
|
141
|
+
state: 'applied',
|
|
142
|
+
appliedAt: input.at,
|
|
143
|
+
};
|
|
144
|
+
this.#operationReceipts.set(key, updated);
|
|
145
|
+
return structuredCloneControl(updated);
|
|
146
|
+
}
|
|
147
|
+
async recordOperationReceipt(receipt) {
|
|
148
|
+
const key = `${receipt.changesetId}\u0000${receipt.operationIndex}`;
|
|
149
|
+
const existing = this.#operationReceipts.get(key);
|
|
150
|
+
if (existing !== undefined) {
|
|
151
|
+
if (JSON.stringify(existing) !== JSON.stringify(receipt)) {
|
|
152
|
+
throw new VictControlError('VICT_CONTROL_OPERATION_RECEIPT_COLLISION', 'An operation receipt already exists with different content.');
|
|
153
|
+
}
|
|
154
|
+
return; // idempotent re-record
|
|
155
|
+
}
|
|
156
|
+
this.#operationReceipts.set(key, structuredCloneControl(receipt));
|
|
157
|
+
}
|
|
158
|
+
async listOperationReceipts(changesetId) {
|
|
159
|
+
return [...this.#operationReceipts.values()]
|
|
160
|
+
.filter((receipt) => receipt.changesetId === changesetId)
|
|
161
|
+
.sort((a, b) => a.operationIndex - b.operationIndex)
|
|
162
|
+
.map((receipt) => structuredCloneControl(receipt));
|
|
163
|
+
}
|
|
164
|
+
async compareAndSetChangeSetStatus(input) {
|
|
165
|
+
const entry = this.#changesets.get(input.changesetId);
|
|
166
|
+
if (entry === undefined) {
|
|
167
|
+
throw new VictControlError('VICT_CONTROL_CHANGESET_MISSING', 'The ChangeSet does not exist.');
|
|
168
|
+
}
|
|
169
|
+
if (entry.record.status !== input.expectedStatus) {
|
|
170
|
+
throw new VictControlError('VICT_CONTROL_CHANGESET_STATUS_CONFLICT', `The ChangeSet is not in the expected status; one concurrent transition already won.`);
|
|
171
|
+
}
|
|
172
|
+
const updated = {
|
|
173
|
+
...structuredCloneControl(entry.record),
|
|
174
|
+
status: input.nextStatus,
|
|
175
|
+
};
|
|
176
|
+
entry.record = structuredCloneControl(updated);
|
|
177
|
+
return structuredCloneControl(updated);
|
|
178
|
+
}
|
|
179
|
+
async publishRelease(record) {
|
|
180
|
+
const existing = this.#releases.get(record.releaseVersion);
|
|
181
|
+
if (existing !== undefined) {
|
|
182
|
+
if (JSON.stringify(existing) !== JSON.stringify(record)) {
|
|
183
|
+
throw new VictControlError('VICT_CONTROL_RELEASE_COLLISION', 'A release with this version exists with different content (immutability guard).');
|
|
184
|
+
}
|
|
185
|
+
return; // idempotent republish
|
|
186
|
+
}
|
|
187
|
+
this.#releases.set(record.releaseVersion, structuredCloneControl(record));
|
|
188
|
+
}
|
|
189
|
+
async applyReleaseOperation(input) {
|
|
190
|
+
// In-memory composition: the three steps run inside ONE synchronous
|
|
191
|
+
// critical section (no await between effect and receipt), so the
|
|
192
|
+
// effect/receipt dual-write window cannot interleave.
|
|
193
|
+
await this.publishRelease(input.release);
|
|
194
|
+
const selection = await this.selectRelease(input.selection);
|
|
195
|
+
await this.markOperationApplied({
|
|
196
|
+
changesetId: input.receipt.changesetId,
|
|
197
|
+
operationIndex: input.receipt.operationIndex,
|
|
198
|
+
at: input.receipt.appliedAt,
|
|
199
|
+
});
|
|
200
|
+
return selection;
|
|
201
|
+
}
|
|
202
|
+
async getRelease(releaseVersion) {
|
|
203
|
+
const found = this.#releases.get(releaseVersion);
|
|
204
|
+
return found === undefined ? undefined : structuredCloneControl(found);
|
|
205
|
+
}
|
|
206
|
+
async listReleases(applicationId) {
|
|
207
|
+
return [...this.#releases.values()]
|
|
208
|
+
.filter((record) => record.applicationId === applicationId)
|
|
209
|
+
.sort((a, b) => (a.releaseVersion < b.releaseVersion ? -1 : 1))
|
|
210
|
+
.map((record) => structuredCloneControl(record));
|
|
211
|
+
}
|
|
212
|
+
async selectRelease(command) {
|
|
213
|
+
const existing = this.#releases.get(command.releaseVersion);
|
|
214
|
+
if (existing === undefined || existing.applicationId !== command.applicationId) {
|
|
215
|
+
throw new VictControlError('VICT_CONTROL_RELEASE_MISSING', 'The release version does not exist for this application.');
|
|
216
|
+
}
|
|
217
|
+
// Operation-identity idempotency: re-application returns the ORIGINAL
|
|
218
|
+
// selection revision without adding another one; conflicting content
|
|
219
|
+
// under the same identity fails closed.
|
|
220
|
+
if (command.operationId !== undefined) {
|
|
221
|
+
const selections = this.#selections.get(command.applicationId) ?? [];
|
|
222
|
+
const existingOp = selections.find((selection) => selection.operationId !== undefined &&
|
|
223
|
+
selection.operationId === command.operationId &&
|
|
224
|
+
selection.reason === command.reason);
|
|
225
|
+
if (existingOp !== undefined) {
|
|
226
|
+
if (existingOp.releaseVersion !== command.releaseVersion) {
|
|
227
|
+
throw new VictControlError('VICT_CONTROL_OPERATION_IDENTITY_CONFLICT', 'The operation identity already applied a different release; conflicting content fails closed.');
|
|
228
|
+
}
|
|
229
|
+
return { selectionRevision: existingOp.selectionRevision };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// SUBJECT-LEVEL base guard, evaluated in the same synchronous critical
|
|
233
|
+
// section as the append (two racing ChangeSets on one base: exactly one
|
|
234
|
+
// winner; the loser receives the stable stale-base conflict).
|
|
235
|
+
if (command.expectedBaseVersion !== undefined) {
|
|
236
|
+
const selected = await this.getSelectedRelease(command.applicationId);
|
|
237
|
+
const selectedVersion = selected?.releaseVersion;
|
|
238
|
+
const expected = command.expectedBaseVersion === CHANGESET_BASE_NONE
|
|
239
|
+
? undefined
|
|
240
|
+
: command.expectedBaseVersion;
|
|
241
|
+
if (selectedVersion !== expected) {
|
|
242
|
+
throw new VictControlError('VICT_CONTROL_BASE_STALE', 'The subject base changed before the selection was applied; exactly one concurrent ChangeSet may win the base.');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const selections = this.#selections.get(command.applicationId) ?? [];
|
|
246
|
+
const selectionRevision = selections.length + 1;
|
|
247
|
+
const record = {
|
|
248
|
+
...structuredCloneControl(command),
|
|
249
|
+
selectionRevision,
|
|
250
|
+
};
|
|
251
|
+
selections.push(record);
|
|
252
|
+
this.#selections.set(command.applicationId, selections);
|
|
253
|
+
return { selectionRevision };
|
|
254
|
+
}
|
|
255
|
+
async getSelectedRelease(applicationId) {
|
|
256
|
+
const selections = this.#selections.get(applicationId) ?? [];
|
|
257
|
+
const latest = selections.at(-1);
|
|
258
|
+
if (latest === undefined) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
return this.getRelease(latest.releaseVersion);
|
|
262
|
+
}
|
|
263
|
+
async listReleaseSelections(applicationId) {
|
|
264
|
+
return (this.#selections.get(applicationId) ?? []).map((record) => structuredCloneControl(record));
|
|
265
|
+
}
|
|
266
|
+
async appendAuditEvent(event) {
|
|
267
|
+
if (this.#audit.some((entry) => entry.auditId === event.auditId)) {
|
|
268
|
+
return; // idempotent append (idempotent by auditId)
|
|
269
|
+
}
|
|
270
|
+
this.#audit.push(structuredCloneControl(event));
|
|
271
|
+
}
|
|
272
|
+
async listAuditEvents(subject) {
|
|
273
|
+
return this.#audit
|
|
274
|
+
.filter((event) => (subject.subjectType === undefined || event.subjectType === subject.subjectType) &&
|
|
275
|
+
(subject.subjectId === undefined || event.subjectId === subject.subjectId))
|
|
276
|
+
.map((event) => structuredCloneControl(event));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// ---- Turns ------------------------------------------------------------------
|
|
280
|
+
/** In-memory AgentTurnStore with compare-and-set state machine enforcement. */
|
|
281
|
+
export class InMemoryAgentTurnStore {
|
|
282
|
+
#turns = new Map();
|
|
283
|
+
#cancels = new Map();
|
|
284
|
+
async createTurnIntent(record) {
|
|
285
|
+
const existing = this.#turns.get(record.turnId);
|
|
286
|
+
if (existing !== undefined) {
|
|
287
|
+
if (JSON.stringify(existing) !== JSON.stringify(record)) {
|
|
288
|
+
throw new VictControlError('VICT_CONTROL_TURN_COLLISION', 'A turn intent with this id already exists with different content.');
|
|
289
|
+
}
|
|
290
|
+
return; // idempotent re-intent
|
|
291
|
+
}
|
|
292
|
+
if (record.status !== 'intent') {
|
|
293
|
+
throw new VictControlError('VICT_CONTROL_TURN_INVALID_STATE', 'A new turn must be recorded as intent.');
|
|
294
|
+
}
|
|
295
|
+
this.#turns.set(record.turnId, structuredCloneControl(record));
|
|
296
|
+
}
|
|
297
|
+
async getTurn(turnId) {
|
|
298
|
+
const record = this.#turns.get(turnId);
|
|
299
|
+
return record === undefined ? undefined : structuredCloneControl(record);
|
|
300
|
+
}
|
|
301
|
+
async listTurns() {
|
|
302
|
+
return [...this.#turns.values()]
|
|
303
|
+
.sort((a, b) => (a.turnId < b.turnId ? -1 : 1))
|
|
304
|
+
.map((record) => structuredCloneControl(record));
|
|
305
|
+
}
|
|
306
|
+
async listOpenTurns() {
|
|
307
|
+
return (await this.listTurns()).filter((record) => record.status !== 'completed' &&
|
|
308
|
+
record.status !== 'failed' &&
|
|
309
|
+
record.status !== 'cancelled' &&
|
|
310
|
+
record.status !== 'blocked');
|
|
311
|
+
}
|
|
312
|
+
async startTurn(turnId, at) {
|
|
313
|
+
return this.#transition(turnId, at, (status) => (status === 'intent' ? 'running' : undefined));
|
|
314
|
+
}
|
|
315
|
+
async awaitApproval(turnId, at, approvalId) {
|
|
316
|
+
return this.#transition(turnId, at, (status) => (status === 'running' ? 'awaiting-approval' : undefined), { approvalId });
|
|
317
|
+
}
|
|
318
|
+
async resumeTurn(turnId, at) {
|
|
319
|
+
return this.#transition(turnId, at, (status) => status === 'awaiting-approval' ? 'running' : undefined);
|
|
320
|
+
}
|
|
321
|
+
async recordTurnCorrelation(turnId, correlation) {
|
|
322
|
+
const record = this.#turns.get(turnId);
|
|
323
|
+
if (record === undefined) {
|
|
324
|
+
throw new VictControlError('VICT_CONTROL_TURN_MISSING', 'The turn does not exist.');
|
|
325
|
+
}
|
|
326
|
+
this.#turns.set(turnId, {
|
|
327
|
+
...record,
|
|
328
|
+
traceId: correlation.traceId ?? record.traceId,
|
|
329
|
+
victRunId: correlation.victRunId ?? record.victRunId,
|
|
330
|
+
mastraRunId: correlation.mastraRunId ?? record.mastraRunId,
|
|
331
|
+
updatedAt: record.updatedAt,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
async completeTurn(command) {
|
|
335
|
+
return this.#transition(command.turnId, command.at, (status) => status === 'running' || status === 'awaiting-approval' || status === 'intent'
|
|
336
|
+
? command.status
|
|
337
|
+
: undefined, { errorCode: command.errorCode });
|
|
338
|
+
}
|
|
339
|
+
async recordCancelIntent(command) {
|
|
340
|
+
const list = this.#cancels.get(command.turnId) ?? [];
|
|
341
|
+
if (list.some((entry) => entry.cancelId === command.cancelId)) {
|
|
342
|
+
return { accepted: false, duplicate: true };
|
|
343
|
+
}
|
|
344
|
+
list.push({
|
|
345
|
+
cancelId: command.cancelId,
|
|
346
|
+
actorId: command.actorId,
|
|
347
|
+
reasonCode: command.reasonCode,
|
|
348
|
+
at: command.at,
|
|
349
|
+
});
|
|
350
|
+
this.#cancels.set(command.turnId, list);
|
|
351
|
+
return { accepted: true, duplicate: false };
|
|
352
|
+
}
|
|
353
|
+
async hasCancelIntent(turnId) {
|
|
354
|
+
return (this.#cancels.get(turnId) ?? []).length > 0;
|
|
355
|
+
}
|
|
356
|
+
async reconcileTurn(command) {
|
|
357
|
+
const record = this.#turns.get(command.turnId);
|
|
358
|
+
if (record === undefined) {
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
if (record.status === 'completed' ||
|
|
362
|
+
record.status === 'failed' ||
|
|
363
|
+
record.status === 'cancelled' ||
|
|
364
|
+
record.status === 'blocked') {
|
|
365
|
+
return structuredCloneControl(record); // already terminal
|
|
366
|
+
}
|
|
367
|
+
const updated = {
|
|
368
|
+
...record,
|
|
369
|
+
status: command.status,
|
|
370
|
+
errorCode: command.reasonCode,
|
|
371
|
+
terminalAt: command.at,
|
|
372
|
+
updatedAt: command.at,
|
|
373
|
+
};
|
|
374
|
+
this.#turns.set(command.turnId, updated);
|
|
375
|
+
return structuredCloneControl(updated);
|
|
376
|
+
}
|
|
377
|
+
async listCancelIntents(turnId) {
|
|
378
|
+
return [...(this.#cancels.get(turnId) ?? [])];
|
|
379
|
+
}
|
|
380
|
+
#transition(turnId, at, allowed, extra = {}) {
|
|
381
|
+
const record = this.#turns.get(turnId);
|
|
382
|
+
if (record === undefined) {
|
|
383
|
+
throw new VictControlError('VICT_CONTROL_TURN_MISSING', 'The turn does not exist.');
|
|
384
|
+
}
|
|
385
|
+
const next = allowed(record.status);
|
|
386
|
+
if (next === undefined) {
|
|
387
|
+
throw new VictControlError('VICT_CONTROL_TURN_INVALID_TRANSITION', `The turn status '${record.status}' cannot transition as requested.`);
|
|
388
|
+
}
|
|
389
|
+
const updated = {
|
|
390
|
+
...record,
|
|
391
|
+
status: next,
|
|
392
|
+
updatedAt: at,
|
|
393
|
+
...(next === 'completed' || next === 'failed' || next === 'cancelled' || next === 'blocked'
|
|
394
|
+
? { terminalAt: at }
|
|
395
|
+
: {}),
|
|
396
|
+
...(extra.errorCode !== undefined ? { errorCode: extra.errorCode } : {}),
|
|
397
|
+
};
|
|
398
|
+
this.#turns.set(turnId, updated);
|
|
399
|
+
return structuredCloneControl(updated);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// ---- Tool invocations ---------------------------------------------------------
|
|
403
|
+
/** In-memory AgentToolInvocationStore (durable-before-invocation semantics). */
|
|
404
|
+
export class InMemoryAgentToolInvocationStore {
|
|
405
|
+
#invocations = new Map();
|
|
406
|
+
#byKey = new Map();
|
|
407
|
+
#turnSlots = new Map();
|
|
408
|
+
#turnSlotMax = new Map();
|
|
409
|
+
async allocateTurnToolSlot(input) {
|
|
410
|
+
const key = `${input.turnId}\u0000${input.toolName}\u0000${input.argDigest}`;
|
|
411
|
+
const existing = this.#turnSlots.get(key);
|
|
412
|
+
if (existing !== undefined) {
|
|
413
|
+
return structuredCloneControl(existing); // same logical request: same slot
|
|
414
|
+
}
|
|
415
|
+
const slot = (this.#turnSlotMax.get(input.turnId) ?? 0) + 1;
|
|
416
|
+
this.#turnSlotMax.set(input.turnId, slot);
|
|
417
|
+
const allocation = {
|
|
418
|
+
slot,
|
|
419
|
+
turnId: input.turnId,
|
|
420
|
+
toolName: input.toolName,
|
|
421
|
+
argDigest: input.argDigest,
|
|
422
|
+
toolCallId: `slot-${slot}-${digest(input.turnId).slice(0, 12)}`,
|
|
423
|
+
};
|
|
424
|
+
this.#turnSlots.set(key, allocation);
|
|
425
|
+
return structuredCloneControl(allocation);
|
|
426
|
+
}
|
|
427
|
+
async recordInvocationIntent(record) {
|
|
428
|
+
const existingByKey = this.#byKey.get(record.idempotencyKey);
|
|
429
|
+
if (existingByKey !== undefined) {
|
|
430
|
+
const existing = this.#invocations.get(existingByKey);
|
|
431
|
+
if (existing !== undefined && existing.argDigest !== record.argDigest) {
|
|
432
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_KEY_COLLISION', 'The idempotency key exists with a different canonical argument digest.');
|
|
433
|
+
}
|
|
434
|
+
if (existing !== undefined) {
|
|
435
|
+
return structuredCloneControl(existing); // idempotent retry
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const existing = this.#invocations.get(record.invocationId);
|
|
439
|
+
if (existing !== undefined) {
|
|
440
|
+
if (JSON.stringify(existing) !== JSON.stringify(record)) {
|
|
441
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_COLLISION', 'An invocation with this id already exists with different content.');
|
|
442
|
+
}
|
|
443
|
+
return structuredCloneControl(existing);
|
|
444
|
+
}
|
|
445
|
+
if (record.status !== 'intent') {
|
|
446
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_INVALID_STATE', 'A new invocation must be recorded as intent (durable-before-invocation).');
|
|
447
|
+
}
|
|
448
|
+
this.#invocations.set(record.invocationId, structuredCloneControl(record));
|
|
449
|
+
this.#byKey.set(record.idempotencyKey, record.invocationId);
|
|
450
|
+
return structuredCloneControl(record);
|
|
451
|
+
}
|
|
452
|
+
async getInvocation(invocationId) {
|
|
453
|
+
const record = this.#invocations.get(invocationId);
|
|
454
|
+
return record === undefined ? undefined : structuredCloneControl(record);
|
|
455
|
+
}
|
|
456
|
+
async getInvocationByIdempotencyKey(idempotencyKey) {
|
|
457
|
+
const invocationId = this.#byKey.get(idempotencyKey);
|
|
458
|
+
return invocationId === undefined ? undefined : this.getInvocation(invocationId);
|
|
459
|
+
}
|
|
460
|
+
async updateInvocationStatus(command) {
|
|
461
|
+
const record = this.#invocations.get(command.invocationId);
|
|
462
|
+
if (record === undefined) {
|
|
463
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_MISSING', 'The invocation does not exist.');
|
|
464
|
+
}
|
|
465
|
+
const ORDER = {
|
|
466
|
+
intent: 0,
|
|
467
|
+
approved: 1,
|
|
468
|
+
running: 2,
|
|
469
|
+
completed: 3,
|
|
470
|
+
failed: 3,
|
|
471
|
+
declined: 3,
|
|
472
|
+
cancelled: 3,
|
|
473
|
+
outcome_unknown: 3,
|
|
474
|
+
};
|
|
475
|
+
const orderOf = (status) => ORDER[status] ?? 0;
|
|
476
|
+
if (orderOf(command.status) < orderOf(record.status)) {
|
|
477
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_REGRESSION', 'An invocation status may only move forward.');
|
|
478
|
+
}
|
|
479
|
+
if (orderOf(record.status) >= 3 && command.status !== record.status) {
|
|
480
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_TERMINAL', 'The invocation is already terminal; late results are fenced.');
|
|
481
|
+
}
|
|
482
|
+
const updated = {
|
|
483
|
+
...record,
|
|
484
|
+
status: command.status,
|
|
485
|
+
updatedAt: command.at,
|
|
486
|
+
completedAt: orderOf(command.status) >= 3 ? command.at : record.completedAt,
|
|
487
|
+
...(command.resultSummary !== undefined ? { resultSummary: command.resultSummary } : {}),
|
|
488
|
+
...(command.errorCode !== undefined ? { errorCode: command.errorCode } : {}),
|
|
489
|
+
};
|
|
490
|
+
this.#invocations.set(command.invocationId, updated);
|
|
491
|
+
return structuredCloneControl(updated);
|
|
492
|
+
}
|
|
493
|
+
async listInvocationsForTurn(turnId) {
|
|
494
|
+
return [...this.#invocations.values()]
|
|
495
|
+
.filter((record) => record.turnId === turnId)
|
|
496
|
+
.sort((a, b) => (a.invocationId < b.invocationId ? -1 : 1))
|
|
497
|
+
.map((record) => structuredCloneControl(record));
|
|
498
|
+
}
|
|
499
|
+
async claimInvocationRun(command) {
|
|
500
|
+
const record = this.#invocations.get(command.invocationId);
|
|
501
|
+
if (record === undefined) {
|
|
502
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_MISSING', 'The invocation does not exist.');
|
|
503
|
+
}
|
|
504
|
+
if (record.status === 'running') {
|
|
505
|
+
// Exactly one live owner: a second claim is the duplicate of the
|
|
506
|
+
// current owner and NEVER mutates the record.
|
|
507
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_OWNER_ACTIVE', 'The invocation attempt is already claimed by a live owner.');
|
|
508
|
+
}
|
|
509
|
+
if (record.status !== 'intent' && record.status !== 'approved') {
|
|
510
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_TERMINAL', 'The invocation is already terminal; no new attempt can be claimed.');
|
|
511
|
+
}
|
|
512
|
+
const updated = {
|
|
513
|
+
...record,
|
|
514
|
+
status: 'running',
|
|
515
|
+
updatedAt: command.at,
|
|
516
|
+
runFenceToken: command.fenceToken,
|
|
517
|
+
runFenceAt: command.at,
|
|
518
|
+
runOwnerIdentity: command.ownerIdentity,
|
|
519
|
+
runGeneration: (record.runGeneration ?? 0) + 1,
|
|
520
|
+
};
|
|
521
|
+
this.#invocations.set(command.invocationId, updated);
|
|
522
|
+
return structuredCloneControl(updated);
|
|
523
|
+
}
|
|
524
|
+
async settleInvocationRun(command) {
|
|
525
|
+
const record = this.#invocations.get(command.invocationId);
|
|
526
|
+
if (record === undefined) {
|
|
527
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_MISSING', 'The invocation does not exist.');
|
|
528
|
+
}
|
|
529
|
+
// The EXACT attempt binding comes first: a stale owner (an earlier
|
|
530
|
+
// fence token, e.g. after reconciliation re-fenced the record) can
|
|
531
|
+
// never settle — not even idempotently — a later generation.
|
|
532
|
+
if ((record.runFenceToken ?? undefined) !== command.fenceToken) {
|
|
533
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_FENCE_MISMATCH', 'The settlement fence does not bind the current attempt owner.');
|
|
534
|
+
}
|
|
535
|
+
if (record.status === 'running') {
|
|
536
|
+
const updated = {
|
|
537
|
+
...record,
|
|
538
|
+
status: command.status,
|
|
539
|
+
updatedAt: command.at,
|
|
540
|
+
completedAt: command.at,
|
|
541
|
+
...(command.resultSummary !== undefined ? { resultSummary: command.resultSummary } : {}),
|
|
542
|
+
...(command.errorCode !== undefined ? { errorCode: command.errorCode } : {}),
|
|
543
|
+
};
|
|
544
|
+
this.#invocations.set(command.invocationId, updated);
|
|
545
|
+
return structuredCloneControl(updated);
|
|
546
|
+
}
|
|
547
|
+
// Terminal idempotency ONLY on the exact requested state and binding.
|
|
548
|
+
if (record.status === command.status &&
|
|
549
|
+
record.errorCode === command.errorCode &&
|
|
550
|
+
record.resultSummary === command.resultSummary) {
|
|
551
|
+
return structuredCloneControl(record);
|
|
552
|
+
}
|
|
553
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_TERMINAL', 'The invocation is already terminal with a different settlement.');
|
|
554
|
+
}
|
|
555
|
+
async settleInvocationPending(command) {
|
|
556
|
+
const record = this.#invocations.get(command.invocationId);
|
|
557
|
+
if (record === undefined) {
|
|
558
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_MISSING', 'The invocation does not exist.');
|
|
559
|
+
}
|
|
560
|
+
if (record.status === 'running') {
|
|
561
|
+
// A claimed (live-owned) attempt is never touched by a late
|
|
562
|
+
// pre-running settlement.
|
|
563
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_OWNER_ACTIVE', 'The invocation attempt is claimed by a live owner.');
|
|
564
|
+
}
|
|
565
|
+
if (record.status === 'intent' || record.status === 'approved') {
|
|
566
|
+
const updated = {
|
|
567
|
+
...record,
|
|
568
|
+
status: command.status,
|
|
569
|
+
updatedAt: command.at,
|
|
570
|
+
completedAt: command.at,
|
|
571
|
+
...(command.errorCode !== undefined ? { errorCode: command.errorCode } : {}),
|
|
572
|
+
};
|
|
573
|
+
this.#invocations.set(command.invocationId, updated);
|
|
574
|
+
return structuredCloneControl(updated);
|
|
575
|
+
}
|
|
576
|
+
// Terminal idempotency ONLY on the exact requested state and binding.
|
|
577
|
+
if (record.status === command.status && record.errorCode === command.errorCode) {
|
|
578
|
+
return structuredCloneControl(record);
|
|
579
|
+
}
|
|
580
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_TERMINAL', 'The invocation is already terminal with a different disposition.');
|
|
581
|
+
}
|
|
582
|
+
async reconcileAbandonedRun(command) {
|
|
583
|
+
const record = this.#invocations.get(command.invocationId);
|
|
584
|
+
if (record === undefined) {
|
|
585
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_MISSING', 'The invocation does not exist.');
|
|
586
|
+
}
|
|
587
|
+
// Exact observed-state binding: only a genuinely running attempt under
|
|
588
|
+
// the exact observed fence is reconciled. Anything else is refused
|
|
589
|
+
// (idempotent truthfulness, never a state-changing guess).
|
|
590
|
+
if (record.status !== 'running' ||
|
|
591
|
+
(record.runFenceToken ?? '') !== command.observedFenceToken) {
|
|
592
|
+
throw new VictControlError('VICT_CONTROL_INVOCATION_FENCE_MISMATCH', 'The observed durable state is not the abandoned attempt requested.');
|
|
593
|
+
}
|
|
594
|
+
const updated = {
|
|
595
|
+
...record,
|
|
596
|
+
status: 'outcome_unknown',
|
|
597
|
+
updatedAt: command.at,
|
|
598
|
+
completedAt: command.at,
|
|
599
|
+
errorCode: 'VICT_CONTROL_INVOCATION_RUN_RECONCILED',
|
|
600
|
+
runFenceToken: command.reconciledFenceToken,
|
|
601
|
+
runFenceAt: command.at,
|
|
602
|
+
runGeneration: (record.runGeneration ?? 0) + 1,
|
|
603
|
+
};
|
|
604
|
+
this.#invocations.set(command.invocationId, updated);
|
|
605
|
+
return structuredCloneControl(updated);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// ---- Approvals ---------------------------------------------------------------
|
|
609
|
+
/** In-memory AgentApprovalStore (first decision wins; exact binding). */
|
|
610
|
+
export class InMemoryAgentApprovalStore {
|
|
611
|
+
#approvals = new Map();
|
|
612
|
+
#byInvocation = new Map();
|
|
613
|
+
async createPendingApproval(record) {
|
|
614
|
+
if (this.#approvals.has(record.approvalId)) {
|
|
615
|
+
const existing = this.#approvals.get(record.approvalId);
|
|
616
|
+
if (JSON.stringify(existing) !== JSON.stringify(record)) {
|
|
617
|
+
throw new VictControlError('VICT_CONTROL_APPROVAL_COLLISION', 'An approval with this id already exists with different content.');
|
|
618
|
+
}
|
|
619
|
+
return structuredCloneControl(existing);
|
|
620
|
+
}
|
|
621
|
+
if (this.#byInvocation.has(record.invocationId)) {
|
|
622
|
+
// One pending approval per invocation: a duplicate request for the
|
|
623
|
+
// same logical invocation reuses the existing record.
|
|
624
|
+
const existingId = this.#byInvocation.get(record.invocationId);
|
|
625
|
+
const existing = this.#approvals.get(existingId);
|
|
626
|
+
if (existing !== undefined && existing.status === 'pending') {
|
|
627
|
+
return structuredCloneControl(existing);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (record.status !== 'pending') {
|
|
631
|
+
throw new VictControlError('VICT_CONTROL_APPROVAL_INVALID_STATE', 'A new approval record must be pending.');
|
|
632
|
+
}
|
|
633
|
+
this.#approvals.set(record.approvalId, structuredCloneControl(record));
|
|
634
|
+
this.#byInvocation.set(record.invocationId, record.approvalId);
|
|
635
|
+
return structuredCloneControl(record);
|
|
636
|
+
}
|
|
637
|
+
async getApproval(approvalId) {
|
|
638
|
+
const record = this.#approvals.get(approvalId);
|
|
639
|
+
return record === undefined ? undefined : structuredCloneControl(record);
|
|
640
|
+
}
|
|
641
|
+
async listOpenApprovals() {
|
|
642
|
+
return [...this.#approvals.values()]
|
|
643
|
+
.filter((record) => record.status === 'pending')
|
|
644
|
+
.sort((a, b) => (a.approvalId < b.approvalId ? -1 : 1))
|
|
645
|
+
.map((record) => structuredCloneControl(record));
|
|
646
|
+
}
|
|
647
|
+
async listApprovalsForInvocation(invocationId) {
|
|
648
|
+
const approvalId = this.#byInvocation.get(invocationId);
|
|
649
|
+
if (approvalId === undefined) {
|
|
650
|
+
return [];
|
|
651
|
+
}
|
|
652
|
+
const record = this.#approvals.get(approvalId);
|
|
653
|
+
return record === undefined ? [] : [structuredCloneControl(record)];
|
|
654
|
+
}
|
|
655
|
+
async decideApproval(command) {
|
|
656
|
+
const record = this.#approvals.get(command.approvalId);
|
|
657
|
+
if (record === undefined) {
|
|
658
|
+
throw new VictControlError('VICT_CONTROL_APPROVAL_MISSING', 'The approval record does not exist.');
|
|
659
|
+
}
|
|
660
|
+
if (record.status === 'approved' || record.status === 'declined') {
|
|
661
|
+
if (record.status === command.decision) {
|
|
662
|
+
return structuredCloneControl(record); // idempotent identical decision
|
|
663
|
+
}
|
|
664
|
+
throw new VictControlError('VICT_CONTROL_APPROVAL_CONFLICT', 'A competing decision already stands; the first durable decision is the winner.');
|
|
665
|
+
}
|
|
666
|
+
if (record.status === 'expired') {
|
|
667
|
+
throw new VictControlError('VICT_CONTROL_APPROVAL_EXPIRED', 'The approval request expired and cannot be decided.');
|
|
668
|
+
}
|
|
669
|
+
const updated = {
|
|
670
|
+
...record,
|
|
671
|
+
status: command.decision,
|
|
672
|
+
decidedAt: command.decidedAt,
|
|
673
|
+
approverActorId: command.approverActorId,
|
|
674
|
+
...(command.decisionReason !== undefined ? { decisionReason: command.decisionReason } : {}),
|
|
675
|
+
};
|
|
676
|
+
this.#approvals.set(command.approvalId, updated);
|
|
677
|
+
return structuredCloneControl(updated);
|
|
678
|
+
}
|
|
679
|
+
async expireApproval(command) {
|
|
680
|
+
const record = this.#approvals.get(command.approvalId);
|
|
681
|
+
if (record === undefined) {
|
|
682
|
+
throw new VictControlError('VICT_CONTROL_APPROVAL_MISSING', 'The approval record does not exist.');
|
|
683
|
+
}
|
|
684
|
+
if (record.status === 'expired') {
|
|
685
|
+
return structuredCloneControl(record); // idempotent
|
|
686
|
+
}
|
|
687
|
+
if (record.status !== 'pending') {
|
|
688
|
+
throw new VictControlError('VICT_CONTROL_APPROVAL_INVALID_STATE', 'Only a pending approval can expire.');
|
|
689
|
+
}
|
|
690
|
+
const updated = { ...record, status: 'expired', decidedAt: command.at };
|
|
691
|
+
this.#approvals.set(command.approvalId, updated);
|
|
692
|
+
return structuredCloneControl(updated);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// ---- Stream ledger -----------------------------------------------------------
|
|
696
|
+
/** In-memory AgentStreamLedgerStore (per-stream monotonic sequences).
|
|
697
|
+
*
|
|
698
|
+
* The RAW ledger port enforces the final `vict.agent-stream@1` schema at
|
|
699
|
+
* the store boundary: plain-JS callers cannot persist unknown kinds,
|
|
700
|
+
* non-canonical payloads, or mismatched content. Validation happens
|
|
701
|
+
* BEFORE any sequence state or storage mutates, so a rejected write
|
|
702
|
+
* leaves the stream exactly as before.
|
|
703
|
+
*/
|
|
704
|
+
export class InMemoryAgentStreamLedgerStore {
|
|
705
|
+
#streams = new Map();
|
|
706
|
+
#events = new Map();
|
|
707
|
+
async appendEvent(command) {
|
|
708
|
+
// The gate: compute the sequence that WOULD be assigned and validate
|
|
709
|
+
// the full event BEFORE incrementing any state.
|
|
710
|
+
const next = (this.#streams.get(command.streamId) ?? 0) + 1;
|
|
711
|
+
validateStreamLedgerAppend({
|
|
712
|
+
streamId: command.streamId,
|
|
713
|
+
kind: command.kind,
|
|
714
|
+
payload: command.payload,
|
|
715
|
+
assignedSeq: next,
|
|
716
|
+
});
|
|
717
|
+
this.#streams.set(command.streamId, next);
|
|
718
|
+
const persisted = isDurableStreamKind(command.kind);
|
|
719
|
+
if (persisted) {
|
|
720
|
+
const list = this.#events.get(command.streamId) ?? [];
|
|
721
|
+
list.push({
|
|
722
|
+
streamId: command.streamId,
|
|
723
|
+
seq: next,
|
|
724
|
+
kind: command.kind,
|
|
725
|
+
payload: command.payload,
|
|
726
|
+
createdAt: command.at,
|
|
727
|
+
});
|
|
728
|
+
this.#events.set(command.streamId, list);
|
|
729
|
+
}
|
|
730
|
+
return { seq: next, persisted };
|
|
731
|
+
}
|
|
732
|
+
async latestSeq(streamId) {
|
|
733
|
+
return this.#streams.get(streamId) ?? 0;
|
|
734
|
+
}
|
|
735
|
+
async listEventsFrom(streamId, afterSeq, limit) {
|
|
736
|
+
const matched = (this.#events.get(streamId) ?? []).filter((event) => event.seq > afterSeq);
|
|
737
|
+
const bounded = limit !== undefined ? matched.slice(0, Math.max(0, limit)) : matched;
|
|
738
|
+
return bounded.map((event) => structuredCloneControl(event));
|
|
739
|
+
}
|
|
740
|
+
async listStreamIds() {
|
|
741
|
+
return [...this.#streams.keys()].sort();
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
// ---- Command idempotency ------------------------------------------------------
|
|
745
|
+
// ---- Command idempotency ------------------------------------------------------
|
|
746
|
+
/**
|
|
747
|
+
* In-memory CommandIdempotencyStore. Receipts are NAMESPACED by
|
|
748
|
+
* (actor, command, key); `pending` receipts carry a durable lease
|
|
749
|
+
* (owner + expiry + attempts) so a crashed claimer never sticks the key:
|
|
750
|
+
* a retrying caller takes the expired lease over. Deterministic failures
|
|
751
|
+
* settle `failed` (replayed); retryable failures RELEASE the claim.
|
|
752
|
+
*/
|
|
753
|
+
export class InMemoryCommandIdempotencyStore {
|
|
754
|
+
#receipts = new Map();
|
|
755
|
+
static #name(receipt) {
|
|
756
|
+
return `${receipt.actorId}\u0000${receipt.command}\u0000${receipt.idempotencyKey}`;
|
|
757
|
+
}
|
|
758
|
+
async claimReceipt(record) {
|
|
759
|
+
const key = InMemoryCommandIdempotencyStore.#name(record);
|
|
760
|
+
const existing = this.#receipts.get(key);
|
|
761
|
+
if (existing !== undefined) {
|
|
762
|
+
return 'exists';
|
|
763
|
+
}
|
|
764
|
+
this.#receipts.set(key, structuredCloneControl(record));
|
|
765
|
+
return 'claimed';
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Fenced settlement: the presented fence token must match the receipt's
|
|
769
|
+
* CURRENT claim generation exactly. A stale owner receives a stable
|
|
770
|
+
* non-echoing conflict and the receipt is left BYTE-IDENTICAL.
|
|
771
|
+
*/
|
|
772
|
+
#requireLivePendingFence(key, fenceToken) {
|
|
773
|
+
const found = this.#receipts.get(key);
|
|
774
|
+
if (found === undefined) {
|
|
775
|
+
throw new VictControlError('VICT_IDEMPOTENCY_RECEIPT_MISSING', 'No idempotency receipt exists for this key namespace.');
|
|
776
|
+
}
|
|
777
|
+
if (found.status !== 'pending') {
|
|
778
|
+
// Settled receipts are never re-settled (duplicate replay is handled
|
|
779
|
+
// by the service layer): a fence mismatch is the truthful outcome.
|
|
780
|
+
throw new VictControlError(VICT_IDEMPOTENCY_FENCE_CONFLICT, 'VICT_IDEMPOTENCY_FENCE_CONFLICT: the idempotency claim is no longer pending; the presented fence token does not own it.');
|
|
781
|
+
}
|
|
782
|
+
if (found.fenceToken === undefined || found.fenceToken !== fenceToken) {
|
|
783
|
+
throw new VictControlError(VICT_IDEMPOTENCY_FENCE_CONFLICT, 'VICT_IDEMPOTENCY_FENCE_CONFLICT: the settlement fence token does not match the current claim generation; the claim is untouched.');
|
|
784
|
+
}
|
|
785
|
+
return found;
|
|
786
|
+
}
|
|
787
|
+
async getReceipt(name) {
|
|
788
|
+
const found = this.#receipts.get(InMemoryCommandIdempotencyStore.#name(name));
|
|
789
|
+
return found === undefined ? undefined : structuredCloneControl(found);
|
|
790
|
+
}
|
|
791
|
+
async findReceiptByActorKey(input) {
|
|
792
|
+
for (const receipt of this.#receipts.values()) {
|
|
793
|
+
if (receipt.actorId === input.actorId && receipt.idempotencyKey === input.idempotencyKey) {
|
|
794
|
+
return structuredCloneControl(receipt);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return undefined;
|
|
798
|
+
}
|
|
799
|
+
async completeReceipt(input) {
|
|
800
|
+
const key = InMemoryCommandIdempotencyStore.#name(input);
|
|
801
|
+
const found = this.#requireLivePendingFence(key, input.fenceToken);
|
|
802
|
+
const updated = {
|
|
803
|
+
...found,
|
|
804
|
+
status: 'completed',
|
|
805
|
+
resultJson: input.resultJson,
|
|
806
|
+
settledAt: input.at,
|
|
807
|
+
owner: undefined,
|
|
808
|
+
leaseUntil: undefined,
|
|
809
|
+
fenceToken: undefined,
|
|
810
|
+
};
|
|
811
|
+
this.#receipts.set(key, structuredCloneControl(updated));
|
|
812
|
+
}
|
|
813
|
+
async failReceipt(input) {
|
|
814
|
+
const key = InMemoryCommandIdempotencyStore.#name(input);
|
|
815
|
+
const found = this.#requireLivePendingFence(key, input.fenceToken);
|
|
816
|
+
const updated = {
|
|
817
|
+
...found,
|
|
818
|
+
status: 'failed',
|
|
819
|
+
responseCode: input.responseCode,
|
|
820
|
+
settledAt: input.at,
|
|
821
|
+
owner: undefined,
|
|
822
|
+
leaseUntil: undefined,
|
|
823
|
+
fenceToken: undefined,
|
|
824
|
+
};
|
|
825
|
+
this.#receipts.set(key, structuredCloneControl(updated));
|
|
826
|
+
}
|
|
827
|
+
async releaseReceipt(input) {
|
|
828
|
+
const key = InMemoryCommandIdempotencyStore.#name(input);
|
|
829
|
+
// FENCED release: only the current claim generation may release the
|
|
830
|
+
// claim; a stale owner receives a conflict and the live claim survives
|
|
831
|
+
// byte-identically.
|
|
832
|
+
this.#requireLivePendingFence(key, input.fenceToken);
|
|
833
|
+
this.#receipts.delete(key); // claimable again (retryable failure)
|
|
834
|
+
}
|
|
835
|
+
async takeOverExpiredLease(input) {
|
|
836
|
+
const key = InMemoryCommandIdempotencyStore.#name(input);
|
|
837
|
+
const found = this.#receipts.get(key);
|
|
838
|
+
if (found === undefined) {
|
|
839
|
+
return { outcome: 'missing' };
|
|
840
|
+
}
|
|
841
|
+
if (found.status !== 'pending') {
|
|
842
|
+
return { outcome: 'not-expired' }; // settled: replay path handles it
|
|
843
|
+
}
|
|
844
|
+
if ((found.leaseUntil ?? 0) > input.at) {
|
|
845
|
+
return { outcome: 'not-expired' }; // the previous owner may still be executing
|
|
846
|
+
}
|
|
847
|
+
const attempts = found.attempts + 1;
|
|
848
|
+
const fenceToken = commandIdempotencyFenceToken({
|
|
849
|
+
actorId: input.actorId,
|
|
850
|
+
command: input.command,
|
|
851
|
+
idempotencyKey: input.idempotencyKey,
|
|
852
|
+
owner: input.owner,
|
|
853
|
+
attempts,
|
|
854
|
+
});
|
|
855
|
+
const updated = {
|
|
856
|
+
...found,
|
|
857
|
+
owner: input.owner,
|
|
858
|
+
leaseUntil: input.leaseUntil,
|
|
859
|
+
attempts,
|
|
860
|
+
fenceToken,
|
|
861
|
+
};
|
|
862
|
+
this.#receipts.set(key, structuredCloneControl(updated));
|
|
863
|
+
return { outcome: 'taken', fenceToken };
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
/** The in-memory Stage 06B store set. */
|
|
867
|
+
export function createInMemoryAgentControlStores() {
|
|
868
|
+
return {
|
|
869
|
+
actors: new InMemoryActorDirectory(),
|
|
870
|
+
control: new InMemoryControlPlaneStore(),
|
|
871
|
+
turns: new InMemoryAgentTurnStore(),
|
|
872
|
+
invocations: new InMemoryAgentToolInvocationStore(),
|
|
873
|
+
approvals: new InMemoryAgentApprovalStore(),
|
|
874
|
+
streamLedger: new InMemoryAgentStreamLedgerStore(),
|
|
875
|
+
commandIdempotency: new InMemoryCommandIdempotencyStore(),
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
/** Structured clone that preserves plain canonical records. */
|
|
879
|
+
function structuredCloneControl(value) {
|
|
880
|
+
return JSON.parse(JSON.stringify(value));
|
|
881
|
+
}
|
|
882
|
+
/** Deterministic id helper for in-memory fixtures. */
|
|
883
|
+
export function deterministicId(prefix, seed) {
|
|
884
|
+
return `${prefix}_${digest(seed)}`;
|
|
885
|
+
}
|
|
886
|
+
//# sourceMappingURL=control-in-memory.js.map
|