narraeon 0.6.0 → 0.6.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/README.md +11 -0
- package/README.zh-CN.md +8 -0
- package/dist/node/protocol/v1.js +12 -2
- package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
- package/dist/node/runtime/V1Runtime.js +5 -5
- package/dist/node/runtime/archive/WorldArchives.js +14 -46
- package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
- package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
- package/dist/node/runtime/authoring/ModelToolConversation.js +25 -2
- package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
- package/dist/node/runtime/config/dataRootLock.js +108 -0
- package/dist/node/runtime/content/ContentWorkspace.js +0 -10
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +7 -44
- package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
- package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
- package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
- package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +32 -207
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +29 -239
- package/dist/node/runtime/play/PlayCallChain.js +338 -366
- package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
- package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
- package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
- package/dist/node/runtime/prompt/WorldMaintenanceReport.js +39 -7
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +14 -6
- package/dist/node/runtime/setting/SettingImprovementSession.js +39 -25
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
- package/dist/node/runtime/world/FileNativeWorldStore.js +230 -781
- package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
- package/dist/node/runtime/world/WorldActivity.js +14 -22
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
- package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
- package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
- package/dist/node/runtime/world/WorldQueue.js +46 -0
- package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +1 -13
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +29 -2
- package/dist/node/server/mobile.js +52 -0
- package/dist/node/server/startWebServer.js +15 -8
- package/dist/web/assets/index-CXnFaYK9.js +1323 -0
- package/dist/web/assets/{index-BJ-1Mb0e.css → index-LPMV0pl3.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
- package/dist/web/assets/index-BgBL8G-A.js +0 -1323
|
@@ -3,201 +3,62 @@ import { isWorldExtensionControl, } from "../../protocol/worldExtensions.js";
|
|
|
3
3
|
import { Buffer } from "node:buffer";
|
|
4
4
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { readdir, readFile, rm } from "node:fs/promises";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
export class ArtifactStoreCorruptionError extends ArtifactStoreInvariantError {
|
|
17
|
-
constructor(message, options) {
|
|
18
|
-
super(message, options);
|
|
19
|
-
this.name = "ArtifactStoreCorruptionError";
|
|
20
|
-
}
|
|
21
|
-
}
|
|
9
|
+
import { worldQueueFor } from "../world/WorldQueue.js";
|
|
10
|
+
import { validatePlayPresetArtifactPayload, } from "../play/FileNativePlayPresetStore.js";
|
|
11
|
+
import { ArtifactStoreCorruptionError, ArtifactStoreInvariantError, artifactSequenceFileName, assertOperation, assertRawRecord, hasExactKeys, identityHash, isJsonValue, isMissing, isRecord, parseArtifactJson, payloadFingerprint, rawRecordFingerprint, stableSerialize, validAttachment, headOrdinal, writeArtifactJson, } from "./ArtifactStoreFiles.js";
|
|
12
|
+
import { hasLegacyArtifactState, migrateLegacyArtifactStore, } from "./LegacyArtifactStoreMigration.js";
|
|
13
|
+
export { ArtifactStoreCorruptionError, ArtifactStoreInvariantError };
|
|
22
14
|
const maxPayloadBytes = 4 * 1024 * 1024;
|
|
23
15
|
/**
|
|
24
|
-
* File-native artifact module.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
16
|
+
* File-native artifact module. Artifacts live in the world directory as
|
|
17
|
+
* `artifacts/records/<sequence>.json` and `artifacts/operations/<hash>.json`.
|
|
18
|
+
* A record carries its own status and head and is updated in place; there is
|
|
19
|
+
* no event log. Forks and archives copy the world directory and then rewrite
|
|
20
|
+
* identities with {@link forkArtifactClosure}.
|
|
28
21
|
*/
|
|
29
22
|
export class FileNativeArtifactStore {
|
|
30
|
-
#
|
|
31
|
-
#extensionControls;
|
|
23
|
+
#dataRoot;
|
|
32
24
|
#worldsRoot;
|
|
25
|
+
#extensionControls;
|
|
26
|
+
/** Records with `save: "none"` are never written to disk. */
|
|
33
27
|
#memoryRecords = new Map();
|
|
34
|
-
#memoryEvents = new Map();
|
|
35
28
|
#mutationContext = new AsyncLocalStorage();
|
|
36
29
|
constructor(dataRoot) {
|
|
37
|
-
this.#
|
|
38
|
-
this.#
|
|
39
|
-
this.#
|
|
30
|
+
this.#dataRoot = resolve(dataRoot);
|
|
31
|
+
this.#worldsRoot = join(this.#dataRoot, "worlds-file-native");
|
|
32
|
+
this.#extensionControls = new WorldExtensionRequests(this.#worldsRoot);
|
|
40
33
|
}
|
|
41
|
-
/** Control acceptance and artifact publication share
|
|
42
|
-
* Callers holding the Authority lock always acquire it before this lock. */
|
|
34
|
+
/** Control acceptance and artifact publication share the in-process world queue. */
|
|
43
35
|
static withWorldControlsMutation(dataRoot, worldId, action) {
|
|
44
|
-
return
|
|
45
|
-
}
|
|
46
|
-
/** The immutable closure publishes atomically with the derived world. */
|
|
47
|
-
async stageFork(input) {
|
|
48
|
-
await this.#withWorldMutation(input.sourceWorldId, async () => {
|
|
49
|
-
const projection = await this.readActiveProjection(input.sourceWorldId, undefined, input.timeline);
|
|
50
|
-
const retained = new Set(projection
|
|
51
|
-
.filter((item) => item.save === "commit" && item.attachment !== undefined)
|
|
52
|
-
.map((item) => item.recordId));
|
|
53
|
-
const effective = (await this.#effectiveRecords(input.sourceWorldId)).filter(({ record }) => retained.has(record.recordId));
|
|
54
|
-
const operations = [];
|
|
55
|
-
const records = [];
|
|
56
|
-
const events = [];
|
|
57
|
-
let sequence = Math.max(0, ...effective.map(({ record }) => record.sequence));
|
|
58
|
-
for (const { record, head } of effective) {
|
|
59
|
-
const source = (await this.#readOperation(record.operationId));
|
|
60
|
-
const operationId = `artifact-fork:${input.targetWorldId}:${identityHash(record.operationId)}`;
|
|
61
|
-
if (!operations.some((operation) => operation.context.operationId === operationId)) {
|
|
62
|
-
operations.push({
|
|
63
|
-
...source,
|
|
64
|
-
context: {
|
|
65
|
-
...source.context,
|
|
66
|
-
worldId: input.targetWorldId,
|
|
67
|
-
operationId,
|
|
68
|
-
},
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
const copy = {
|
|
72
|
-
...record,
|
|
73
|
-
worldId: input.targetWorldId,
|
|
74
|
-
operationId,
|
|
75
|
-
sequence: record.sequence,
|
|
76
|
-
};
|
|
77
|
-
copy.recordFingerprint = rawRecordFingerprint(copy);
|
|
78
|
-
records.push(copy);
|
|
79
|
-
events.push({
|
|
80
|
-
schemaVersion: 1,
|
|
81
|
-
kind: "activate",
|
|
82
|
-
sequence: ++sequence,
|
|
83
|
-
operationId,
|
|
84
|
-
recordId: copy.recordId,
|
|
85
|
-
});
|
|
86
|
-
events.push({
|
|
87
|
-
schemaVersion: 1,
|
|
88
|
-
kind: "bind_head",
|
|
89
|
-
sequence: ++sequence,
|
|
90
|
-
operationId,
|
|
91
|
-
recordId: copy.recordId,
|
|
92
|
-
head: head,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
const snapshot = {
|
|
96
|
-
schemaVersion: 1,
|
|
97
|
-
worldId: input.targetWorldId,
|
|
98
|
-
operations,
|
|
99
|
-
records,
|
|
100
|
-
events,
|
|
101
|
-
};
|
|
102
|
-
await mkdir(join(input.targetWorldRoot, "runtime"), { recursive: true });
|
|
103
|
-
await writeDurableJson(join(input.targetWorldRoot, "runtime", "artifact-fork.json"), snapshot, true);
|
|
104
|
-
});
|
|
36
|
+
return worldQueueFor(resolve(dataRoot)).run(worldId, action);
|
|
105
37
|
}
|
|
106
|
-
async
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
await stat(join(this.#worldRoot(worldId), "fork-restored.json"));
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
catch (error) {
|
|
114
|
-
if (!isMissing(error))
|
|
115
|
-
throw error;
|
|
116
|
-
}
|
|
117
|
-
try {
|
|
118
|
-
await stat(join(this.#worldsRoot, worldId, "runtime", "artifact-fork.json"));
|
|
119
|
-
}
|
|
120
|
-
catch (error) {
|
|
121
|
-
if (isMissing(error))
|
|
122
|
-
return;
|
|
123
|
-
throw error;
|
|
124
|
-
}
|
|
125
|
-
await this.#withWorldMutation(worldId, () => Promise.resolve());
|
|
126
|
-
}
|
|
127
|
-
async #restoreFork(worldId) {
|
|
128
|
-
if (dirname(resolve(this.#worldsRoot, worldId)) !== this.#worldsRoot)
|
|
129
|
-
return;
|
|
130
|
-
const marker = join(this.#worldRoot(worldId), "fork-restored.json");
|
|
131
|
-
try {
|
|
132
|
-
await readFile(marker);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
catch (error) {
|
|
136
|
-
if (!isMissing(error))
|
|
137
|
-
throw error;
|
|
138
|
-
}
|
|
139
|
-
let snapshot;
|
|
140
|
-
try {
|
|
141
|
-
snapshot = parseArtifactJson(await readFile(join(this.#worldsRoot, worldId, "runtime", "artifact-fork.json"), "utf8"), "artifact fork");
|
|
142
|
-
}
|
|
143
|
-
catch (error) {
|
|
144
|
-
if (isMissing(error))
|
|
38
|
+
async finishRequest(context, failed) {
|
|
39
|
+
await this.#withWorldMutation(context.worldId, async () => {
|
|
40
|
+
const operation = await this.#readOperation(context.worldId, context.operationId);
|
|
41
|
+
if (operation?.requestAttempts[context.requestId] !== context.requestAttempt)
|
|
145
42
|
return;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"records",
|
|
154
|
-
"events",
|
|
155
|
-
]) ||
|
|
156
|
-
snapshot.schemaVersion !== 1 ||
|
|
157
|
-
snapshot.worldId !== worldId ||
|
|
158
|
-
!Array.isArray(snapshot.operations) ||
|
|
159
|
-
!Array.isArray(snapshot.records) ||
|
|
160
|
-
!Array.isArray(snapshot.events))
|
|
161
|
-
throw new ArtifactStoreCorruptionError("Artifact fork snapshot is invalid");
|
|
162
|
-
for (const operation of snapshot.operations) {
|
|
163
|
-
assertOperation(operation);
|
|
164
|
-
if (operation.context.worldId !== worldId)
|
|
165
|
-
throw new ArtifactStoreCorruptionError("Artifact fork world mismatch");
|
|
166
|
-
}
|
|
167
|
-
for (const record of snapshot.records) {
|
|
168
|
-
assertRawRecord(record);
|
|
169
|
-
if (record.worldId !== worldId ||
|
|
170
|
-
!snapshot.operations.some((operation) => operation.context.operationId === record.operationId))
|
|
171
|
-
throw new ArtifactStoreCorruptionError("Artifact fork record mismatch");
|
|
172
|
-
}
|
|
173
|
-
for (const event of snapshot.events) {
|
|
174
|
-
assertEvent(event);
|
|
175
|
-
if (!snapshot.records.some((record) => record.recordId === event.recordId &&
|
|
176
|
-
record.operationId === event.operationId))
|
|
177
|
-
throw new ArtifactStoreCorruptionError("Artifact fork event mismatch");
|
|
178
|
-
}
|
|
179
|
-
// No mutation can start until this idempotent restore finishes under the world lock.
|
|
180
|
-
for (const operation of snapshot.operations)
|
|
181
|
-
await this.#writeOperation(operation);
|
|
182
|
-
for (const record of snapshot.records)
|
|
183
|
-
await writeDurableJson(join(this.#worldRoot(worldId), "records", artifactSequenceFileName(record.sequence)), record, false);
|
|
184
|
-
for (const event of snapshot.events)
|
|
185
|
-
await writeDurableJson(join(this.#worldRoot(worldId), "events", artifactSequenceFileName(event.sequence)), event, false);
|
|
186
|
-
await mkdir(this.#worldRoot(worldId), { recursive: true });
|
|
187
|
-
await writeDurableJson(marker, { schemaVersion: 1 }, false);
|
|
43
|
+
await this.#writeOperation({
|
|
44
|
+
...operation,
|
|
45
|
+
requests: (operation.requests ?? []).map((item) => item.requestId === context.requestId
|
|
46
|
+
? { ...item, status: failed ? "failed" : "completed" }
|
|
47
|
+
: item),
|
|
48
|
+
});
|
|
49
|
+
});
|
|
188
50
|
}
|
|
189
51
|
async beginOperation(context) {
|
|
190
52
|
return this.#withWorldMutation(context.worldId, () => this.#beginOperation(context));
|
|
191
53
|
}
|
|
192
54
|
async #beginOperation(context) {
|
|
193
55
|
assertContext(context);
|
|
194
|
-
const existing = await this.#readOperation(context.operationId);
|
|
56
|
+
const existing = await this.#readOperation(context.worldId, context.operationId);
|
|
195
57
|
if (existing !== null && !sameContext(existing.context, context))
|
|
196
58
|
throw new ArtifactStoreInvariantError("Artifact operation is bound to a different frozen payload");
|
|
197
59
|
// A direct orchestrator/store caller must not rely on a later UI read to
|
|
198
|
-
// invalidate artifacts bound to the parent head.
|
|
199
|
-
|
|
200
|
-
await this.reconcileHead(context.worldId, context.parentHead, context.operationId);
|
|
60
|
+
// invalidate artifacts bound to the parent head.
|
|
61
|
+
await this.#reconcileHead(context.worldId, context.parentHead, context.operationId);
|
|
201
62
|
const priorOperations = await this.#operationsForWorld(context.worldId);
|
|
202
63
|
if (existing === null)
|
|
203
64
|
await this.#writeOperation({
|
|
@@ -209,31 +70,24 @@ export class FileNativeArtifactStore {
|
|
|
209
70
|
artifactProjectionStatus: "active",
|
|
210
71
|
requestAttempts: {},
|
|
211
72
|
});
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
// is an actually open extension, while projection invalidation is tracked
|
|
216
|
-
// separately for late raw results.
|
|
217
|
-
const prior = await this.#effectiveRecords(context.worldId);
|
|
73
|
+
// An open extension is superseded even when it has not emitted anything
|
|
74
|
+
// yet; its execution status is kept unless it is actually open.
|
|
75
|
+
const records = await this.#records(context.worldId);
|
|
218
76
|
for (const priorOperation of priorOperations) {
|
|
219
77
|
if (priorOperation.context.operationId === context.operationId)
|
|
220
78
|
continue;
|
|
221
79
|
const openExtension = isOpenExtension(priorOperation);
|
|
222
80
|
const operationAlreadySuperseded = priorOperation.artifactProjectionStatus === "superseded";
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
recordId: item.record.recordId,
|
|
234
|
-
reason: "new_operation",
|
|
235
|
-
sequence: await this.#nextSequence(context.worldId),
|
|
236
|
-
});
|
|
81
|
+
for (const record of records) {
|
|
82
|
+
if (record.operationId !== priorOperation.context.operationId ||
|
|
83
|
+
record.status !== "active")
|
|
84
|
+
continue;
|
|
85
|
+
if (!(openExtension ||
|
|
86
|
+
operationAlreadySuperseded ||
|
|
87
|
+
record.invalidation === "new_operation"))
|
|
88
|
+
continue;
|
|
89
|
+
await this.#putRecord({ ...record, status: "superseded" });
|
|
90
|
+
}
|
|
237
91
|
if (openExtension) {
|
|
238
92
|
const next = {
|
|
239
93
|
...priorOperation,
|
|
@@ -241,8 +95,7 @@ export class FileNativeArtifactStore {
|
|
|
241
95
|
extensionStatus: "superseded",
|
|
242
96
|
message: "This extension was superseded by a newer operation",
|
|
243
97
|
};
|
|
244
|
-
|
|
245
|
-
await this.#writeOperation(next);
|
|
98
|
+
await this.#writeOperation(next);
|
|
246
99
|
}
|
|
247
100
|
}
|
|
248
101
|
}
|
|
@@ -251,7 +104,7 @@ export class FileNativeArtifactStore {
|
|
|
251
104
|
}
|
|
252
105
|
async #beginRequestAttempt(context) {
|
|
253
106
|
assertRequestContext(context);
|
|
254
|
-
const operation = await this.#readOperation(context.operationId);
|
|
107
|
+
const operation = await this.#readOperation(context.worldId, context.operationId);
|
|
255
108
|
if (operation === null)
|
|
256
109
|
throw new ArtifactStoreInvariantError("Artifact request attempt is missing an operation checkpoint");
|
|
257
110
|
if (!sameContext(operation.context, context))
|
|
@@ -267,7 +120,7 @@ export class FileNativeArtifactStore {
|
|
|
267
120
|
? []
|
|
268
121
|
: [context.frozenResources.mount])),
|
|
269
122
|
];
|
|
270
|
-
|
|
123
|
+
const requests = [
|
|
271
124
|
...(operation.requests ?? []).filter((item) => item.requestId !== context.requestId),
|
|
272
125
|
{
|
|
273
126
|
requestId: context.requestId,
|
|
@@ -279,48 +132,63 @@ export class FileNativeArtifactStore {
|
|
|
279
132
|
const current = operation.requestAttempts[context.requestId];
|
|
280
133
|
if (current !== undefined && context.requestAttempt < current)
|
|
281
134
|
return;
|
|
282
|
-
|
|
283
|
-
// between these writes, cold rebuild still treats the old attempt as
|
|
284
|
-
// stale, and a retry below completes the missing supersede events.
|
|
285
|
-
if (current === undefined || context.requestAttempt > current) {
|
|
135
|
+
if (current === undefined || context.requestAttempt > current)
|
|
286
136
|
await this.#writeOperation({
|
|
287
137
|
...operation,
|
|
138
|
+
requests,
|
|
288
139
|
requestAttempts: {
|
|
289
140
|
...operation.requestAttempts,
|
|
290
141
|
[context.requestId]: context.requestAttempt,
|
|
291
142
|
},
|
|
292
143
|
});
|
|
144
|
+
// A clear made by an earlier attempt of this request is rolled back: that
|
|
145
|
+
// attempt did not finish, and its retry decides again.
|
|
146
|
+
const records = await this.#records(context.worldId);
|
|
147
|
+
for (const record of records) {
|
|
148
|
+
if (record.status === "cleared" &&
|
|
149
|
+
record.clearedBy?.operationId === context.operationId &&
|
|
150
|
+
record.clearedBy.requestId === context.requestId &&
|
|
151
|
+
record.clearedBy.requestAttempt < context.requestAttempt) {
|
|
152
|
+
await this.#putRecord({
|
|
153
|
+
...record,
|
|
154
|
+
status: record.clearedBy.priorStatus,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
293
157
|
}
|
|
294
|
-
const records = await this.#recordsForOperation(context.operationId);
|
|
295
158
|
for (const record of records) {
|
|
296
|
-
if (record.
|
|
297
|
-
record.
|
|
159
|
+
if (record.operationId !== context.operationId ||
|
|
160
|
+
record.requestId !== context.requestId ||
|
|
161
|
+
record.requestAttempt >= context.requestAttempt ||
|
|
162
|
+
record.status === "superseded")
|
|
298
163
|
continue;
|
|
299
|
-
|
|
300
|
-
schemaVersion: 1,
|
|
301
|
-
kind: "supersede",
|
|
302
|
-
operationId: context.operationId,
|
|
303
|
-
recordId: record.recordId,
|
|
304
|
-
requestId: record.requestId,
|
|
305
|
-
requestAttempt: record.requestAttempt,
|
|
306
|
-
reason: "request_attempt_superseded",
|
|
307
|
-
sequence: await this.#nextSequence(context.worldId),
|
|
308
|
-
};
|
|
309
|
-
await this.#appendRecordEvent(record, event);
|
|
164
|
+
await this.#putRecord({ ...record, status: "superseded" });
|
|
310
165
|
}
|
|
311
166
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
167
|
+
/**
|
|
168
|
+
* Remove the operations and records whose head is after restoredHead. A
|
|
169
|
+
* rewind discards those rounds, and their replies, attachments and extension
|
|
170
|
+
* results must not survive on disk (ADR-0045). Each artifact is one file, so
|
|
171
|
+
* the discard deletes files.
|
|
172
|
+
*/
|
|
173
|
+
async discardAfterHead(worldId, restoredHead) {
|
|
174
|
+
return this.#withWorldMutation(worldId, () => this.#discardAfterHead(worldId, restoredHead));
|
|
175
|
+
}
|
|
176
|
+
async #discardAfterHead(worldId, restoredHead) {
|
|
177
|
+
const limit = headOrdinal(restoredHead);
|
|
178
|
+
const operations = await this.#operationsForWorld(worldId);
|
|
179
|
+
const discardedOperations = new Set(operations
|
|
180
|
+
.filter((operation) => headOrdinal(operation.head ?? operation.context.parentHead) > limit)
|
|
181
|
+
.map(({ context }) => context.operationId));
|
|
182
|
+
for (const record of await this.#records(worldId)) {
|
|
183
|
+
if (!discardedOperations.has(record.operationId) &&
|
|
184
|
+
headOrdinal(record.head ?? record.parentHead) <= limit)
|
|
185
|
+
continue;
|
|
186
|
+
await this.#removeRecord(record);
|
|
187
|
+
}
|
|
188
|
+
for (const operationId of discardedOperations) {
|
|
189
|
+
await rm(this.#operationPath(worldId, operationId), { force: true });
|
|
190
|
+
this.#memoryRecords.delete(operationId);
|
|
191
|
+
}
|
|
324
192
|
}
|
|
325
193
|
async reconcileHead(worldId, head, exceptOperationId) {
|
|
326
194
|
return this.#withWorldMutation(worldId, () => this.#reconcileHead(worldId, head, exceptOperationId));
|
|
@@ -329,26 +197,18 @@ export class FileNativeArtifactStore {
|
|
|
329
197
|
if (worldId.trim() === "" || head.trim() === "")
|
|
330
198
|
throw new ArtifactStoreInvariantError("Artifact head-reconciliation arguments are invalid");
|
|
331
199
|
const operations = await this.#operationsForWorld(worldId);
|
|
332
|
-
const
|
|
200
|
+
const records = await this.#records(worldId);
|
|
333
201
|
const affected = new Set();
|
|
334
|
-
for (const
|
|
335
|
-
|
|
336
|
-
|
|
202
|
+
for (const record of records) {
|
|
203
|
+
if (record.operationId === exceptOperationId ||
|
|
204
|
+
record.status !== "active")
|
|
337
205
|
continue;
|
|
338
|
-
if (
|
|
206
|
+
if (record.invalidation !== "head_change")
|
|
339
207
|
continue;
|
|
340
|
-
|
|
341
|
-
if (boundHead === head)
|
|
208
|
+
if ((record.head ?? record.parentHead) === head)
|
|
342
209
|
continue;
|
|
343
|
-
await this.#
|
|
344
|
-
|
|
345
|
-
kind: "supersede",
|
|
346
|
-
operationId,
|
|
347
|
-
recordId: item.record.recordId,
|
|
348
|
-
reason: "head_change",
|
|
349
|
-
sequence: await this.#nextSequence(worldId),
|
|
350
|
-
});
|
|
351
|
-
affected.add(operationId);
|
|
210
|
+
await this.#putRecord({ ...record, status: "superseded" });
|
|
211
|
+
affected.add(record.operationId);
|
|
352
212
|
}
|
|
353
213
|
for (const operation of operations) {
|
|
354
214
|
const operationId = operation.context.operationId;
|
|
@@ -357,10 +217,10 @@ export class FileNativeArtifactStore {
|
|
|
357
217
|
const boundHead = operation.head ?? operation.context.parentHead;
|
|
358
218
|
if (boundHead === head)
|
|
359
219
|
continue;
|
|
360
|
-
const hasActiveHeadBoundRecord =
|
|
361
|
-
status === "active" &&
|
|
220
|
+
const hasActiveHeadBoundRecord = records.some((record) => record.operationId === operationId &&
|
|
221
|
+
record.status === "active" &&
|
|
362
222
|
record.invalidation === "head_change" &&
|
|
363
|
-
(
|
|
223
|
+
(record.head ?? record.parentHead) !== head);
|
|
364
224
|
if (!hasActiveHeadBoundRecord && !isOpenProjectionOperation(operation))
|
|
365
225
|
continue;
|
|
366
226
|
affected.add(operationId);
|
|
@@ -397,7 +257,7 @@ export class FileNativeArtifactStore {
|
|
|
397
257
|
return artifactFailure(payload.message);
|
|
398
258
|
if (input.toolCallId.trim() === "")
|
|
399
259
|
return artifactFailure("Tool-call ID cannot be empty");
|
|
400
|
-
const operation = await this.#readOperation(input.context.operationId);
|
|
260
|
+
const operation = await this.#readOperation(input.context.worldId, input.context.operationId);
|
|
401
261
|
if (operation === null)
|
|
402
262
|
throw new ArtifactStoreInvariantError("Artifact emit is missing an operation checkpoint");
|
|
403
263
|
if (!sameContext(operation.context, input.context))
|
|
@@ -407,12 +267,13 @@ export class FileNativeArtifactStore {
|
|
|
407
267
|
throw new ArtifactStoreInvariantError("Artifact emit request attempt is not registered");
|
|
408
268
|
const fingerprint = payloadFingerprint(input.output, payload.value);
|
|
409
269
|
const callFingerprint = artifactCallFingerprint("artifact_emit", input.context, { output: declaration.name, payload: payload.value });
|
|
410
|
-
const prior = await this.#recordsForOperation(input.context.operationId);
|
|
270
|
+
const prior = await this.#recordsForOperation(input.context.worldId, input.context.operationId);
|
|
271
|
+
// A tool-call ID is an identity only within one operation. A cold retry
|
|
272
|
+
// re-calls the model and gets new IDs (ADR-0044), so no wider ledger exists.
|
|
411
273
|
const previousByCall = prior.find((record) => record.toolCallId === input.toolCallId);
|
|
412
274
|
if (previousByCall !== undefined) {
|
|
413
275
|
if (previousByCall.callFingerprint !== callFingerprint)
|
|
414
276
|
return artifactFailure("The same artifact tool-call ID is bound to different arguments");
|
|
415
|
-
await this.#repairRecordPublication(previousByCall, operation, currentAttempt);
|
|
416
277
|
return {
|
|
417
278
|
ok: true,
|
|
418
279
|
idempotent: true,
|
|
@@ -420,10 +281,6 @@ export class FileNativeArtifactStore {
|
|
|
420
281
|
markdown: `# Runtime artifact already exists\n\noutput=${input.output}`,
|
|
421
282
|
};
|
|
422
283
|
}
|
|
423
|
-
const previousEvent = (await this.#events(input.context.worldId)).find((event) => event.operationId === input.context.operationId &&
|
|
424
|
-
event.toolCallId === input.toolCallId);
|
|
425
|
-
if (previousEvent !== undefined)
|
|
426
|
-
return artifactFailure("The same artifact tool-call ID is bound to a different tool call");
|
|
427
284
|
const staleAttempt = currentAttempt !== input.context.requestAttempt;
|
|
428
285
|
const sameOutput = prior.filter((record) => record.requestId === input.context.requestId &&
|
|
429
286
|
record.requestAttempt === input.context.requestAttempt &&
|
|
@@ -439,135 +296,38 @@ export class FileNativeArtifactStore {
|
|
|
439
296
|
.reduce((total, record) => total + byteLength(record.payload, record.contentType), 0);
|
|
440
297
|
if (totalBytes + payloadBytes > input.context.maxArtifactBytes)
|
|
441
298
|
return artifactFailure("Follow-up request artifacts exceed maxArtifactBytes");
|
|
442
|
-
const
|
|
299
|
+
const sequence = await this.#nextSequence(input.context.worldId);
|
|
300
|
+
const base = newRecord(input, declaration, payload.value, {
|
|
301
|
+
sequence,
|
|
302
|
+
fingerprint,
|
|
303
|
+
callFingerprint,
|
|
304
|
+
});
|
|
443
305
|
const stale = isStaleOperation(operation) ||
|
|
444
306
|
staleAttempt ||
|
|
445
|
-
!(await this.#extensionControls.visible(
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
await this.#appendRecordEvent(record, {
|
|
464
|
-
schemaVersion: 1,
|
|
465
|
-
kind: "bind_head",
|
|
466
|
-
sequence: await this.#nextSequence(input.context.worldId),
|
|
467
|
-
operationId: input.context.operationId,
|
|
468
|
-
recordId: record.recordId,
|
|
469
|
-
head: operation.head,
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
else {
|
|
474
|
-
await this.#appendRaw(record);
|
|
475
|
-
if (stale)
|
|
476
|
-
await this.#appendRecordEvent(record, {
|
|
477
|
-
schemaVersion: 1,
|
|
478
|
-
kind: "supersede",
|
|
479
|
-
sequence: await this.#nextSequence(input.context.worldId),
|
|
480
|
-
operationId: input.context.operationId,
|
|
481
|
-
recordId: record.recordId,
|
|
482
|
-
requestId: record.requestId,
|
|
483
|
-
requestAttempt: record.requestAttempt,
|
|
484
|
-
reason: "late_superseded_operation_result",
|
|
485
|
-
});
|
|
486
|
-
else if (operation?.status === "core_committed" &&
|
|
487
|
-
operation.head !== undefined) {
|
|
488
|
-
await this.#appendRecordEvent(record, {
|
|
489
|
-
schemaVersion: 1,
|
|
490
|
-
kind: "bind_head",
|
|
491
|
-
sequence: await this.#nextSequence(input.context.worldId),
|
|
492
|
-
operationId: input.context.operationId,
|
|
493
|
-
recordId: record.recordId,
|
|
494
|
-
head: operation.head,
|
|
495
|
-
});
|
|
496
|
-
await this.#appendRecordEvent(record, {
|
|
497
|
-
schemaVersion: 1,
|
|
498
|
-
kind: "activate",
|
|
499
|
-
sequence: await this.#nextSequence(input.context.worldId),
|
|
500
|
-
operationId: input.context.operationId,
|
|
501
|
-
recordId: record.recordId,
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
else if (declaration.save === "operation")
|
|
505
|
-
await this.#appendRecordEvent(record, {
|
|
506
|
-
schemaVersion: 1,
|
|
507
|
-
kind: "activate",
|
|
508
|
-
sequence: await this.#nextSequence(input.context.worldId),
|
|
509
|
-
operationId: input.context.operationId,
|
|
510
|
-
recordId: record.recordId,
|
|
511
|
-
});
|
|
512
|
-
}
|
|
307
|
+
!(await this.#extensionControls.visible(base.worldId, base));
|
|
308
|
+
const committed = !stale &&
|
|
309
|
+
operation.status === "core_committed" &&
|
|
310
|
+
operation.head !== undefined;
|
|
311
|
+
// A stale result is kept as superseded. A live result is active when its
|
|
312
|
+
// core committed or when it does not wait for the core; a commit-saved
|
|
313
|
+
// result waits as pending until markCoreCommitted binds it.
|
|
314
|
+
const status = stale
|
|
315
|
+
? "superseded"
|
|
316
|
+
: committed || declaration.save !== "commit"
|
|
317
|
+
? "active"
|
|
318
|
+
: "pending";
|
|
319
|
+
const record = {
|
|
320
|
+
...base,
|
|
321
|
+
status,
|
|
322
|
+
head: committed ? (operation.head ?? null) : null,
|
|
323
|
+
};
|
|
324
|
+
await this.#putRecord(record);
|
|
513
325
|
return {
|
|
514
326
|
ok: true,
|
|
515
327
|
recordId: record.recordId,
|
|
516
328
|
markdown: `# Runtime artifact accepted\n\noutput=${input.output}`,
|
|
517
329
|
};
|
|
518
330
|
}
|
|
519
|
-
async #repairRecordPublication(record, operation, currentAttempt) {
|
|
520
|
-
const events = await this.#events(record.worldId);
|
|
521
|
-
const hasEvent = (kind, head) => events.some((event) => event.kind === kind &&
|
|
522
|
-
event.operationId === record.operationId &&
|
|
523
|
-
event.recordId === record.recordId &&
|
|
524
|
-
(head === undefined || event.head === head));
|
|
525
|
-
const stale = isStaleOperation(operation) ||
|
|
526
|
-
currentAttempt !== record.requestAttempt ||
|
|
527
|
-
!(await this.#extensionControls.visible(record.worldId, record));
|
|
528
|
-
if (stale) {
|
|
529
|
-
if (!hasEvent("supersede"))
|
|
530
|
-
await this.#appendRecordEvent(record, {
|
|
531
|
-
schemaVersion: 1,
|
|
532
|
-
kind: "supersede",
|
|
533
|
-
sequence: await this.#nextSequence(record.worldId),
|
|
534
|
-
operationId: record.operationId,
|
|
535
|
-
recordId: record.recordId,
|
|
536
|
-
requestId: record.requestId,
|
|
537
|
-
requestAttempt: record.requestAttempt,
|
|
538
|
-
reason: "late_superseded_operation_result",
|
|
539
|
-
});
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
if (operation.status === "core_committed" && operation.head !== undefined) {
|
|
543
|
-
if (!hasEvent("bind_head", operation.head))
|
|
544
|
-
await this.#appendRecordEvent(record, {
|
|
545
|
-
schemaVersion: 1,
|
|
546
|
-
kind: "bind_head",
|
|
547
|
-
sequence: await this.#nextSequence(record.worldId),
|
|
548
|
-
operationId: record.operationId,
|
|
549
|
-
recordId: record.recordId,
|
|
550
|
-
head: operation.head,
|
|
551
|
-
});
|
|
552
|
-
if (!hasEvent("activate"))
|
|
553
|
-
await this.#appendRecordEvent(record, {
|
|
554
|
-
schemaVersion: 1,
|
|
555
|
-
kind: "activate",
|
|
556
|
-
sequence: await this.#nextSequence(record.worldId),
|
|
557
|
-
operationId: record.operationId,
|
|
558
|
-
recordId: record.recordId,
|
|
559
|
-
});
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
if (record.save === "operation" && !hasEvent("activate"))
|
|
563
|
-
await this.#appendRecordEvent(record, {
|
|
564
|
-
schemaVersion: 1,
|
|
565
|
-
kind: "activate",
|
|
566
|
-
sequence: await this.#nextSequence(record.worldId),
|
|
567
|
-
operationId: record.operationId,
|
|
568
|
-
recordId: record.recordId,
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
331
|
async clear(input) {
|
|
572
332
|
return this.#withWorldMutation(input.context.worldId, () => this.#clear(input));
|
|
573
333
|
}
|
|
@@ -578,7 +338,7 @@ export class FileNativeArtifactStore {
|
|
|
578
338
|
return artifactFailure(`Undeclared artifact output: ${input.output}`);
|
|
579
339
|
if (input.toolCallId.trim() === "")
|
|
580
340
|
return artifactFailure("Tool-call ID cannot be empty");
|
|
581
|
-
const operation = await this.#readOperation(input.context.operationId);
|
|
341
|
+
const operation = await this.#readOperation(input.context.worldId, input.context.operationId);
|
|
582
342
|
if (operation === null)
|
|
583
343
|
throw new ArtifactStoreInvariantError("Artifact clear is missing an operation checkpoint");
|
|
584
344
|
if (!sameContext(operation.context, input.context))
|
|
@@ -586,68 +346,37 @@ export class FileNativeArtifactStore {
|
|
|
586
346
|
const currentAttempt = operation.requestAttempts[input.context.requestId];
|
|
587
347
|
if (currentAttempt === undefined)
|
|
588
348
|
throw new ArtifactStoreInvariantError("Artifact clear request attempt is not registered");
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
const prior = records.find((record) => record.toolCallId === input.toolCallId);
|
|
592
|
-
if (prior !== undefined)
|
|
349
|
+
const prior = await this.#recordsForOperation(input.context.worldId, input.context.operationId);
|
|
350
|
+
if (prior.some((record) => record.toolCallId === input.toolCallId))
|
|
593
351
|
return artifactFailure("The same artifact tool-call ID is bound to a different tool call");
|
|
594
|
-
const priorCall = (await this.#events(input.context.worldId)).find((event) => event.operationId === input.context.operationId &&
|
|
595
|
-
event.toolCallId === input.toolCallId);
|
|
596
|
-
if (priorCall !== undefined) {
|
|
597
|
-
if (priorCall.callFingerprint !== clearFingerprint)
|
|
598
|
-
return artifactFailure("The same artifact tool-call ID is bound to different arguments");
|
|
599
|
-
return {
|
|
600
|
-
ok: true,
|
|
601
|
-
idempotent: true,
|
|
602
|
-
markdown: `# Runtime artifact clear confirmed\n\noutput=${input.output}`,
|
|
603
|
-
};
|
|
604
|
-
}
|
|
605
352
|
const staleAttempt = currentAttempt !== input.context.requestAttempt;
|
|
606
353
|
if (isStaleOperation(operation) ||
|
|
607
354
|
staleAttempt ||
|
|
608
|
-
!(await this.#extensionControls.visible(input.context.worldId, input.context)))
|
|
609
|
-
const event = {
|
|
610
|
-
schemaVersion: 1,
|
|
611
|
-
kind: "ignored_call",
|
|
612
|
-
operationId: input.context.operationId,
|
|
613
|
-
output: declaration.name,
|
|
614
|
-
channel: declaration.channel,
|
|
615
|
-
...(declaration.key === undefined ? {} : { key: declaration.key }),
|
|
616
|
-
reason: "stale_clear_ignored",
|
|
617
|
-
sequence: await this.#nextSequence(input.context.worldId),
|
|
618
|
-
toolCallId: input.toolCallId,
|
|
619
|
-
toolName: "artifact_clear",
|
|
620
|
-
callFingerprint: clearFingerprint,
|
|
621
|
-
requestId: input.context.requestId,
|
|
622
|
-
requestAttempt: input.context.requestAttempt,
|
|
623
|
-
};
|
|
624
|
-
// An ignored stale call is a durable identity ledger even for save:none:
|
|
625
|
-
// no raw payload is persisted, but a cold retry must distinguish the
|
|
626
|
-
// exact same call from a changed request/attempt/parameter.
|
|
627
|
-
await this.#appendEventOnce(event);
|
|
355
|
+
!(await this.#extensionControls.visible(input.context.worldId, input.context)))
|
|
628
356
|
return {
|
|
629
357
|
ok: true,
|
|
630
358
|
markdown: `# Runtime artifact clear ignored\n\noutput=${input.output}`,
|
|
631
359
|
};
|
|
360
|
+
// Projection clears are durable even for save:none records. Only the raw
|
|
361
|
+
// payload is ephemeral, so a cold restart cannot resurrect an old panel.
|
|
362
|
+
for (const record of await this.#records(input.context.worldId)) {
|
|
363
|
+
if (record.status === "superseded" ||
|
|
364
|
+
record.status === "cleared" ||
|
|
365
|
+
record.channel !== declaration.channel ||
|
|
366
|
+
record.output !== declaration.name ||
|
|
367
|
+
(declaration.key !== undefined && record.key !== declaration.key))
|
|
368
|
+
continue;
|
|
369
|
+
await this.#putRecord({
|
|
370
|
+
...record,
|
|
371
|
+
status: "cleared",
|
|
372
|
+
clearedBy: {
|
|
373
|
+
operationId: input.context.operationId,
|
|
374
|
+
requestId: input.context.requestId,
|
|
375
|
+
requestAttempt: input.context.requestAttempt,
|
|
376
|
+
priorStatus: record.status,
|
|
377
|
+
},
|
|
378
|
+
});
|
|
632
379
|
}
|
|
633
|
-
const event = {
|
|
634
|
-
schemaVersion: 1,
|
|
635
|
-
kind: "clear",
|
|
636
|
-
operationId: input.context.operationId,
|
|
637
|
-
output: declaration.name,
|
|
638
|
-
channel: declaration.channel,
|
|
639
|
-
...(declaration.key === undefined ? {} : { key: declaration.key }),
|
|
640
|
-
reason: "explicit_clear",
|
|
641
|
-
sequence: await this.#nextSequence(input.context.worldId),
|
|
642
|
-
toolCallId: input.toolCallId,
|
|
643
|
-
toolName: "artifact_clear",
|
|
644
|
-
callFingerprint: clearFingerprint,
|
|
645
|
-
requestId: input.context.requestId,
|
|
646
|
-
requestAttempt: input.context.requestAttempt,
|
|
647
|
-
};
|
|
648
|
-
// Projection clears are durable even for save:none. Only the raw payload
|
|
649
|
-
// is ephemeral; otherwise a cold restart would resurrect an old panel.
|
|
650
|
-
await this.#appendEventOnce(event);
|
|
651
380
|
return {
|
|
652
381
|
ok: true,
|
|
653
382
|
markdown: `# Runtime artifact clear confirmed\n\noutput=${input.output}`,
|
|
@@ -660,7 +389,7 @@ export class FileNativeArtifactStore {
|
|
|
660
389
|
assertContext(context);
|
|
661
390
|
if (head.trim() === "")
|
|
662
391
|
throw new ArtifactStoreInvariantError("Core commit head is invalid");
|
|
663
|
-
const operation = await this.#readOperation(context.operationId);
|
|
392
|
+
const operation = await this.#readOperation(context.worldId, context.operationId);
|
|
664
393
|
if (operation === null)
|
|
665
394
|
throw new ArtifactStoreInvariantError("Core commit is missing an artifact-operation checkpoint");
|
|
666
395
|
if (!sameContext(operation.context, context))
|
|
@@ -675,112 +404,50 @@ export class FileNativeArtifactStore {
|
|
|
675
404
|
if (operation.status === "core_committed") {
|
|
676
405
|
if (operation.head !== head)
|
|
677
406
|
throw new ArtifactStoreInvariantError("Committed artifact operation has a conflicting core head");
|
|
678
|
-
await this.#activateCommittedRecords(operation,
|
|
407
|
+
await this.#activateCommittedRecords(operation, head);
|
|
679
408
|
return;
|
|
680
409
|
}
|
|
681
|
-
|
|
682
|
-
if (recoveredFromMaterializationPending && operation.head !== head)
|
|
683
|
-
throw new ArtifactStoreInvariantError("Artifact operation awaiting materialization has a conflicting core head");
|
|
684
|
-
await this.reconcileHead(context.worldId, head, context.operationId);
|
|
410
|
+
await this.#reconcileHead(context.worldId, head, context.operationId);
|
|
685
411
|
const committed = {
|
|
686
412
|
...operation,
|
|
687
413
|
status: "core_committed",
|
|
688
|
-
extensionStatus: recoveredFromMaterializationPending
|
|
689
|
-
? "not_started"
|
|
690
|
-
: operation.extensionStatus,
|
|
691
|
-
...(recoveredFromMaterializationPending ? { completedRequests: [] } : {}),
|
|
692
414
|
head,
|
|
693
415
|
};
|
|
694
|
-
|
|
695
|
-
delete committed.message;
|
|
696
|
-
// Persist the receipt/head before projection events. A crash after this
|
|
416
|
+
// The head is persisted before records bind to it. A crash after this
|
|
697
417
|
// write is repaired by the idempotent same-head path above.
|
|
698
418
|
await this.#writeOperation(committed);
|
|
699
|
-
await this.#activateCommittedRecords(committed,
|
|
700
|
-
}
|
|
701
|
-
async markCoreMaterializationPending(context, head, message) {
|
|
702
|
-
return this.#withWorldMutation(context.worldId, () => this.#markCoreMaterializationPending(context, head, message));
|
|
703
|
-
}
|
|
704
|
-
async #markCoreMaterializationPending(context, head, message) {
|
|
705
|
-
const operation = await this.#readOperation(context.operationId);
|
|
706
|
-
if (operation === null)
|
|
707
|
-
throw new ArtifactStoreInvariantError("Artifact operation awaiting materialization does not exist");
|
|
708
|
-
if (!sameContext(operation.context, context))
|
|
709
|
-
throw new ArtifactStoreInvariantError("Artifact operation awaiting materialization does not match the frozen operation");
|
|
710
|
-
if (operation.status === "core_failed")
|
|
711
|
-
throw new ArtifactStoreInvariantError("An artifact operation whose core failed cannot await materialization");
|
|
712
|
-
if (operation.status === "core_committed" ||
|
|
713
|
-
operation.status === "completed")
|
|
714
|
-
return summary(operation);
|
|
715
|
-
if (operation.status === "core_materialization_pending" &&
|
|
716
|
-
operation.head !== head)
|
|
717
|
-
throw new ArtifactStoreInvariantError("Artifact operation awaiting materialization has a conflicting core head");
|
|
718
|
-
const pending = {
|
|
719
|
-
...operation,
|
|
720
|
-
status: "core_materialization_pending",
|
|
721
|
-
extensionStatus: "recovery_required",
|
|
722
|
-
message,
|
|
723
|
-
completedRequests: [],
|
|
724
|
-
head,
|
|
725
|
-
};
|
|
726
|
-
await this.#writeOperation(pending);
|
|
727
|
-
return summary(pending);
|
|
419
|
+
await this.#activateCommittedRecords(committed, head);
|
|
728
420
|
}
|
|
729
|
-
async #activateCommittedRecords(operation,
|
|
421
|
+
async #activateCommittedRecords(operation, head) {
|
|
730
422
|
if (operation.artifactProjectionStatus === "superseded")
|
|
731
423
|
return;
|
|
732
|
-
const
|
|
733
|
-
for (const
|
|
734
|
-
|
|
735
|
-
if (record.operationId !== context.operationId)
|
|
736
|
-
continue;
|
|
737
|
-
if (status === "superseded" ||
|
|
738
|
-
status === "cleared" ||
|
|
739
|
-
status === "failed")
|
|
424
|
+
const records = await this.#recordsForOperation(operation.context.worldId, operation.context.operationId);
|
|
425
|
+
for (const record of records) {
|
|
426
|
+
if (record.status === "superseded" || record.status === "cleared")
|
|
740
427
|
continue;
|
|
741
|
-
if (
|
|
428
|
+
if (record.head !== null && record.head !== head)
|
|
742
429
|
throw new ArtifactStoreInvariantError("Artifact record is bound to a different core head");
|
|
743
|
-
if (
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
sequence: await this.#nextSequence(context.worldId),
|
|
751
|
-
});
|
|
752
|
-
if (status === "pending")
|
|
753
|
-
await this.#appendRecordEvent(record, {
|
|
754
|
-
schemaVersion: 1,
|
|
755
|
-
kind: "activate",
|
|
756
|
-
operationId: context.operationId,
|
|
757
|
-
recordId: record.recordId,
|
|
758
|
-
sequence: await this.#nextSequence(context.worldId),
|
|
759
|
-
});
|
|
430
|
+
if (record.head === head && record.status !== "pending")
|
|
431
|
+
continue;
|
|
432
|
+
await this.#putRecord({
|
|
433
|
+
...record,
|
|
434
|
+
head,
|
|
435
|
+
status: record.status === "pending" ? "active" : record.status,
|
|
436
|
+
});
|
|
760
437
|
}
|
|
761
438
|
}
|
|
762
|
-
async markCoreFailed(operationId, message) {
|
|
763
|
-
|
|
764
|
-
if (worldId === null)
|
|
765
|
-
return;
|
|
766
|
-
return this.#withWorldMutation(worldId, () => this.#markCoreFailed(operationId, message));
|
|
439
|
+
async markCoreFailed(worldId, operationId, message) {
|
|
440
|
+
return this.#withWorldMutation(worldId, () => this.#markCoreFailed(worldId, operationId, message));
|
|
767
441
|
}
|
|
768
|
-
async #markCoreFailed(operationId, message) {
|
|
769
|
-
const operation = await this.#readOperation(operationId);
|
|
442
|
+
async #markCoreFailed(worldId, operationId, message) {
|
|
443
|
+
const operation = await this.#readOperation(worldId, operationId);
|
|
770
444
|
if (operation === null)
|
|
771
445
|
return;
|
|
772
446
|
if (operation.status !== "running")
|
|
773
447
|
return;
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
schemaVersion: 1,
|
|
778
|
-
kind: "supersede",
|
|
779
|
-
operationId,
|
|
780
|
-
recordId: record.recordId,
|
|
781
|
-
reason: "core_failed",
|
|
782
|
-
sequence: await this.#nextSequence(operation.context.worldId),
|
|
783
|
-
});
|
|
448
|
+
for (const record of await this.#recordsForOperation(worldId, operationId))
|
|
449
|
+
if (record.status !== "superseded")
|
|
450
|
+
await this.#putRecord({ ...record, status: "superseded" });
|
|
784
451
|
await this.#writeOperation({
|
|
785
452
|
...operation,
|
|
786
453
|
status: "core_failed",
|
|
@@ -793,7 +460,7 @@ export class FileNativeArtifactStore {
|
|
|
793
460
|
return this.#withWorldMutation(context.worldId, () => this.#beginExtension(context));
|
|
794
461
|
}
|
|
795
462
|
async #beginExtension(context) {
|
|
796
|
-
const operation = await this.#readOperation(context.operationId);
|
|
463
|
+
const operation = await this.#readOperation(context.worldId, context.operationId);
|
|
797
464
|
if (operation === null)
|
|
798
465
|
throw new ArtifactStoreInvariantError("Artifact extension is missing an operation checkpoint");
|
|
799
466
|
if (!sameContext(operation.context, context))
|
|
@@ -805,21 +472,13 @@ export class FileNativeArtifactStore {
|
|
|
805
472
|
return;
|
|
806
473
|
await this.#writeOperation({ ...operation, extensionStatus: "running" });
|
|
807
474
|
}
|
|
808
|
-
async completeExtension(operationId, requests) {
|
|
809
|
-
|
|
810
|
-
if (worldId === null)
|
|
811
|
-
return this.#completeExtension(operationId, requests);
|
|
812
|
-
return this.#withWorldMutation(worldId, () => this.#completeExtension(operationId, requests));
|
|
475
|
+
async completeExtension(worldId, operationId, requests) {
|
|
476
|
+
return this.#withWorldMutation(worldId, () => this.#completeExtension(worldId, operationId, requests));
|
|
813
477
|
}
|
|
814
|
-
async #completeExtension(operationId, requests) {
|
|
815
|
-
const operation = await this.#readOperation(operationId);
|
|
478
|
+
async #completeExtension(worldId, operationId, requests) {
|
|
479
|
+
const operation = await this.#readOperation(worldId, operationId);
|
|
816
480
|
if (operation === null)
|
|
817
|
-
return
|
|
818
|
-
operationId,
|
|
819
|
-
status: "recovery_required",
|
|
820
|
-
completedRequests: [],
|
|
821
|
-
coreCommitted: false,
|
|
822
|
-
};
|
|
481
|
+
return recoveryRequired(operationId, [], undefined);
|
|
823
482
|
if (operation.status === "running")
|
|
824
483
|
return summary(operation);
|
|
825
484
|
if (isTerminalExtensionStatus(operation.extensionStatus) ||
|
|
@@ -828,17 +487,7 @@ export class FileNativeArtifactStore {
|
|
|
828
487
|
return summary(operation);
|
|
829
488
|
if (operation.status !== "core_committed")
|
|
830
489
|
throw new ArtifactStoreInvariantError("Artifact extension cannot complete before the core is committed");
|
|
831
|
-
|
|
832
|
-
for (const record of records)
|
|
833
|
-
if (record.save === "none" || record.invalidation === "operation_end")
|
|
834
|
-
await this.#appendRecordEvent(record, {
|
|
835
|
-
schemaVersion: 1,
|
|
836
|
-
kind: "supersede",
|
|
837
|
-
operationId,
|
|
838
|
-
recordId: record.recordId,
|
|
839
|
-
reason: "operation_end",
|
|
840
|
-
sequence: await this.#nextSequence(operation.context.worldId),
|
|
841
|
-
});
|
|
490
|
+
await this.#endOperationRecords(worldId, operationId);
|
|
842
491
|
const next = {
|
|
843
492
|
...operation,
|
|
844
493
|
status: "completed",
|
|
@@ -849,39 +498,20 @@ export class FileNativeArtifactStore {
|
|
|
849
498
|
await this.#writeOperation(next);
|
|
850
499
|
return summary(next);
|
|
851
500
|
}
|
|
852
|
-
async failExtension(operationId, status, message, requests = []) {
|
|
853
|
-
|
|
854
|
-
if (worldId === null)
|
|
855
|
-
return this.#failExtension(operationId, status, message, requests);
|
|
856
|
-
return this.#withWorldMutation(worldId, () => this.#failExtension(operationId, status, message, requests));
|
|
501
|
+
async failExtension(worldId, operationId, status, message, requests = []) {
|
|
502
|
+
return this.#withWorldMutation(worldId, () => this.#failExtension(worldId, operationId, status, message, requests));
|
|
857
503
|
}
|
|
858
|
-
async #failExtension(operationId, status, message, requests
|
|
859
|
-
const operation = await this.#readOperation(operationId);
|
|
504
|
+
async #failExtension(worldId, operationId, status, message, requests) {
|
|
505
|
+
const operation = await this.#readOperation(worldId, operationId);
|
|
860
506
|
if (operation === null)
|
|
861
|
-
return
|
|
862
|
-
operationId,
|
|
863
|
-
status: "recovery_required",
|
|
864
|
-
completedRequests: requests,
|
|
865
|
-
message,
|
|
866
|
-
coreCommitted: false,
|
|
867
|
-
};
|
|
507
|
+
return recoveryRequired(operationId, requests, message);
|
|
868
508
|
if (isTerminalExtensionStatus(operation.extensionStatus) ||
|
|
869
509
|
operation.extensionStatus === "unknown" ||
|
|
870
510
|
operation.extensionStatus === "recovery_required")
|
|
871
511
|
return summary(operation);
|
|
872
512
|
if (operation.status !== "core_committed")
|
|
873
513
|
throw new ArtifactStoreInvariantError("Artifact extension cannot fail before the core is committed");
|
|
874
|
-
|
|
875
|
-
for (const record of records)
|
|
876
|
-
if (record.save === "none" || record.invalidation === "operation_end")
|
|
877
|
-
await this.#appendRecordEvent(record, {
|
|
878
|
-
schemaVersion: 1,
|
|
879
|
-
kind: "supersede",
|
|
880
|
-
operationId,
|
|
881
|
-
recordId: record.recordId,
|
|
882
|
-
reason: "extension_failed",
|
|
883
|
-
sequence: await this.#nextSequence(operation.context.worldId),
|
|
884
|
-
});
|
|
514
|
+
await this.#endOperationRecords(worldId, operationId);
|
|
885
515
|
const next = {
|
|
886
516
|
...operation,
|
|
887
517
|
extensionStatus: status,
|
|
@@ -891,12 +521,20 @@ export class FileNativeArtifactStore {
|
|
|
891
521
|
await this.#writeOperation(next);
|
|
892
522
|
return summary(next);
|
|
893
523
|
}
|
|
894
|
-
|
|
895
|
-
|
|
524
|
+
/** Save-none and operation-end records do not survive the extension that produced them. */
|
|
525
|
+
async #endOperationRecords(worldId, operationId) {
|
|
526
|
+
for (const record of await this.#recordsForOperation(worldId, operationId))
|
|
527
|
+
if ((record.save === "none" || record.invalidation === "operation_end") &&
|
|
528
|
+
record.status !== "superseded")
|
|
529
|
+
await this.#putRecord({ ...record, status: "superseded" });
|
|
530
|
+
}
|
|
531
|
+
async readExtension(worldId, operationId) {
|
|
532
|
+
await this.#ensureReadable(worldId);
|
|
533
|
+
const operation = await this.#readOperation(worldId, operationId);
|
|
896
534
|
return operation === null ? null : summary(operation);
|
|
897
535
|
}
|
|
898
536
|
async readExtensionSummaries(worldId) {
|
|
899
|
-
await this.#
|
|
537
|
+
await this.#ensureReadable(worldId);
|
|
900
538
|
return (await this.#operationsForWorld(worldId))
|
|
901
539
|
.filter((operation) => operation.status !== "core_failed" &&
|
|
902
540
|
!(operation.status === "completed" &&
|
|
@@ -905,51 +543,43 @@ export class FileNativeArtifactStore {
|
|
|
905
543
|
.sort((left, right) => left.context.operationId.localeCompare(right.context.operationId))
|
|
906
544
|
.map(summary);
|
|
907
545
|
}
|
|
908
|
-
async resumeExtension(operationId) {
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
546
|
+
async resumeExtension(worldId, operationId) {
|
|
547
|
+
return this.#withWorldMutation(worldId, async () => {
|
|
548
|
+
const operation = await this.#readOperation(worldId, operationId);
|
|
549
|
+
if (operation === null)
|
|
550
|
+
return {
|
|
551
|
+
operationId,
|
|
552
|
+
status: "recovery_required",
|
|
553
|
+
completedRequests: [],
|
|
554
|
+
message: "No recoverable extension checkpoint was found",
|
|
555
|
+
coreCommitted: false,
|
|
556
|
+
};
|
|
557
|
+
if (operation.status === "running")
|
|
558
|
+
return summary(operation);
|
|
559
|
+
if (isTerminalExtensionStatus(operation.extensionStatus) ||
|
|
560
|
+
operation.extensionStatus === "recovery_required")
|
|
561
|
+
return summary(operation);
|
|
562
|
+
const next = {
|
|
563
|
+
...operation,
|
|
564
|
+
extensionStatus: "recovery_required",
|
|
565
|
+
message: "Extension dispatch state cannot be replayed safely; the original request attempt requires explicit manual recovery",
|
|
923
566
|
};
|
|
924
|
-
|
|
925
|
-
return summary(
|
|
926
|
-
|
|
927
|
-
operation.extensionStatus === "recovery_required")
|
|
928
|
-
return summary(operation);
|
|
929
|
-
const next = {
|
|
930
|
-
...operation,
|
|
931
|
-
extensionStatus: "recovery_required",
|
|
932
|
-
message: "Extension dispatch state cannot be replayed safely; the original request attempt requires explicit manual recovery",
|
|
933
|
-
};
|
|
934
|
-
await this.#writeOperation(next);
|
|
935
|
-
return summary(next);
|
|
567
|
+
await this.#writeOperation(next);
|
|
568
|
+
return summary(next);
|
|
569
|
+
});
|
|
936
570
|
}
|
|
937
571
|
async readActiveProjection(worldId, channel, timeline) {
|
|
938
|
-
await this.#
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
operationStates.set(item.record.operationId, operation);
|
|
946
|
-
}
|
|
947
|
-
const active = effective.filter(({ record, status, head }) => {
|
|
948
|
-
if (status !== "active" || record.projection === "hidden")
|
|
572
|
+
await this.#ensureReadable(worldId);
|
|
573
|
+
const operations = new Map((await this.#operationsForWorld(worldId)).map((operation) => [
|
|
574
|
+
operation.context.operationId,
|
|
575
|
+
operation,
|
|
576
|
+
]));
|
|
577
|
+
const active = (await this.#records(worldId)).filter((record) => {
|
|
578
|
+
if (record.status !== "active" || record.projection === "hidden")
|
|
949
579
|
return false;
|
|
950
580
|
if (channel !== undefined && record.channel !== channel)
|
|
951
581
|
return false;
|
|
952
|
-
const operation =
|
|
582
|
+
const operation = operations.get(record.operationId);
|
|
953
583
|
if (operation === undefined ||
|
|
954
584
|
operation.artifactProjectionStatus === "superseded")
|
|
955
585
|
return false;
|
|
@@ -961,22 +591,21 @@ export class FileNativeArtifactStore {
|
|
|
961
591
|
return false;
|
|
962
592
|
if (record.projection === "transient" && !isActiveInteraction(operation))
|
|
963
593
|
return false;
|
|
964
|
-
if (record.save === "operation" && operation
|
|
594
|
+
if (record.save === "operation" && operation.status === "completed")
|
|
965
595
|
return false;
|
|
966
|
-
if (record.save === "commit" && head === null)
|
|
596
|
+
if (record.save === "commit" && record.head === null)
|
|
967
597
|
return false;
|
|
968
598
|
return true;
|
|
969
599
|
});
|
|
970
|
-
const visible = await Promise.all(active.map(async (
|
|
971
|
-
?
|
|
600
|
+
const visible = await Promise.all(active.map(async (record) => (await this.#extensionControls.visible(worldId, record))
|
|
601
|
+
? record
|
|
972
602
|
: null));
|
|
973
603
|
const selected = selectProjection(visible.filter((item) => item !== null));
|
|
974
|
-
return selected.map((
|
|
975
|
-
...(
|
|
976
|
-
undefined
|
|
604
|
+
return selected.map((record) => ({
|
|
605
|
+
...(operations.get(record.operationId)?.context.attachment === undefined
|
|
977
606
|
? {}
|
|
978
607
|
: {
|
|
979
|
-
attachment: structuredClone(
|
|
608
|
+
attachment: structuredClone(operations.get(record.operationId).context.attachment),
|
|
980
609
|
}),
|
|
981
610
|
recordId: record.recordId,
|
|
982
611
|
worldId: record.worldId,
|
|
@@ -1004,20 +633,19 @@ export class FileNativeArtifactStore {
|
|
|
1004
633
|
projection: record.projection,
|
|
1005
634
|
save: record.save,
|
|
1006
635
|
sequence: record.sequence,
|
|
1007
|
-
head: head ?? record.parentHead,
|
|
636
|
+
head: record.head ?? record.parentHead,
|
|
1008
637
|
}));
|
|
1009
638
|
}
|
|
1010
639
|
async readDebug(worldId, operationId) {
|
|
1011
|
-
await this.#
|
|
1012
|
-
const effective = await this.#effectiveRecords(worldId);
|
|
640
|
+
await this.#ensureReadable(worldId);
|
|
1013
641
|
const operations = new Map((await this.#operationsForWorld(worldId)).map((operation) => [
|
|
1014
642
|
operation.context.operationId,
|
|
1015
643
|
operation,
|
|
1016
644
|
]));
|
|
1017
|
-
return
|
|
1018
|
-
.filter((
|
|
1019
|
-
.sort((left, right) => left.
|
|
1020
|
-
.map((
|
|
645
|
+
return (await this.#records(worldId))
|
|
646
|
+
.filter((record) => operationId === undefined || record.operationId === operationId)
|
|
647
|
+
.sort((left, right) => left.sequence - right.sequence)
|
|
648
|
+
.map((record) => ({
|
|
1021
649
|
...(operations.get(record.operationId)?.context.attachment === undefined
|
|
1022
650
|
? {}
|
|
1023
651
|
: {
|
|
@@ -1031,7 +659,7 @@ export class FileNativeArtifactStore {
|
|
|
1031
659
|
playPresetRevision: record.playPresetRevision,
|
|
1032
660
|
playPresetScriptsEnabled: record.playPresetScriptsEnabled,
|
|
1033
661
|
parentHead: record.parentHead,
|
|
1034
|
-
newHead: head,
|
|
662
|
+
newHead: record.head,
|
|
1035
663
|
requestId: record.requestId,
|
|
1036
664
|
requestAttempt: record.requestAttempt,
|
|
1037
665
|
output: record.output,
|
|
@@ -1053,155 +681,24 @@ export class FileNativeArtifactStore {
|
|
|
1053
681
|
: { frozenPresentation: structuredClone(record.frozenPresentation) }),
|
|
1054
682
|
payload: structuredClone(record.payload),
|
|
1055
683
|
payloadFingerprint: record.payloadFingerprint,
|
|
1056
|
-
status,
|
|
684
|
+
status: record.status,
|
|
1057
685
|
}));
|
|
1058
686
|
}
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
const
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
parentHead: input.context.parentHead,
|
|
1068
|
-
newHead: null,
|
|
1069
|
-
operationId: input.context.operationId,
|
|
1070
|
-
playPresetId: input.context.playPresetId,
|
|
1071
|
-
playPresetRevision: input.context.playPresetRevision,
|
|
1072
|
-
playPresetScriptsEnabled: input.context.playPresetScriptsEnabled,
|
|
1073
|
-
requestId: input.context.requestId,
|
|
1074
|
-
requestAttempt: input.context.requestAttempt,
|
|
1075
|
-
output: declaration.name,
|
|
1076
|
-
channel: declaration.channel,
|
|
1077
|
-
...(declaration.key === undefined ? {} : { key: declaration.key }),
|
|
1078
|
-
contentType: declaration.contentType,
|
|
1079
|
-
...(declaration.renderer === undefined
|
|
1080
|
-
? {}
|
|
1081
|
-
: { renderer: declaration.renderer }),
|
|
1082
|
-
...(declaration.rendererRevision === undefined
|
|
1083
|
-
? {}
|
|
1084
|
-
: { rendererRevision: declaration.rendererRevision }),
|
|
1085
|
-
...(input.context.extensionControl === undefined
|
|
1086
|
-
? {}
|
|
1087
|
-
: {
|
|
1088
|
-
extensionControl: structuredClone(input.context.extensionControl),
|
|
1089
|
-
}),
|
|
1090
|
-
...(input.context.frozenPresentations?.[declaration.name] !== undefined
|
|
1091
|
-
? {
|
|
1092
|
-
frozenPresentation: structuredClone(input.context.frozenPresentations[declaration.name]),
|
|
1093
|
-
}
|
|
1094
|
-
: input.context.frozenResources === undefined
|
|
1095
|
-
? {}
|
|
1096
|
-
: {
|
|
1097
|
-
frozenPresentation: {
|
|
1098
|
-
...structuredClone(input.context.frozenResources),
|
|
1099
|
-
declaration: structuredClone(declaration),
|
|
1100
|
-
},
|
|
1101
|
-
}),
|
|
1102
|
-
payload: structuredClone(payload),
|
|
1103
|
-
save: declaration.save,
|
|
1104
|
-
projection: declaration.strategy,
|
|
1105
|
-
invalidation: declaration.invalidation,
|
|
1106
|
-
toolCallId: input.toolCallId,
|
|
1107
|
-
toolName: "artifact_emit",
|
|
1108
|
-
callFingerprint,
|
|
1109
|
-
payloadFingerprint: fingerprint,
|
|
1110
|
-
status: "pending",
|
|
1111
|
-
};
|
|
1112
|
-
return {
|
|
1113
|
-
...record,
|
|
1114
|
-
recordFingerprint: rawRecordFingerprint(record),
|
|
1115
|
-
};
|
|
1116
|
-
}
|
|
1117
|
-
async #appendRaw(record) {
|
|
1118
|
-
const directory = join(this.#worldRoot(record.worldId), "records");
|
|
1119
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
1120
|
-
await writeDurableJson(join(directory, `${String(record.sequence).padStart(12, "0")}.json`), record, true);
|
|
1121
|
-
}
|
|
1122
|
-
async #appendRecordEvent(record, event) {
|
|
1123
|
-
await this.#appendCallEvent({
|
|
1124
|
-
worldId: record.worldId,
|
|
1125
|
-
parentHead: record.parentHead,
|
|
1126
|
-
operationId: record.operationId,
|
|
1127
|
-
playPresetId: record.playPresetId,
|
|
1128
|
-
playPresetRevision: record.playPresetRevision,
|
|
1129
|
-
playPresetScriptsEnabled: record.playPresetScriptsEnabled,
|
|
1130
|
-
requestId: record.requestId,
|
|
1131
|
-
requestAttempt: record.requestAttempt,
|
|
1132
|
-
maxArtifactBytes: maxPayloadBytes,
|
|
1133
|
-
declarations: [],
|
|
1134
|
-
}, record.save, event);
|
|
1135
|
-
}
|
|
1136
|
-
async #appendCallEvent(context, save, event) {
|
|
1137
|
-
if (save === "none") {
|
|
1138
|
-
const events = this.#memoryEvents.get(context.operationId) ?? [];
|
|
1139
|
-
if (!events.some((candidate) => sameEventIdentity(candidate, event))) {
|
|
1140
|
-
events.push(event);
|
|
1141
|
-
this.#memoryEvents.set(context.operationId, events);
|
|
1142
|
-
}
|
|
1143
|
-
return;
|
|
1144
|
-
}
|
|
1145
|
-
await this.#appendEventOnce(event);
|
|
1146
|
-
}
|
|
1147
|
-
async #appendEvent(event) {
|
|
1148
|
-
const worldId = await this.#worldForOperation(event.operationId);
|
|
1149
|
-
if (worldId === null) {
|
|
1150
|
-
const memory = this.#memoryEvents.get(event.operationId) ?? [];
|
|
1151
|
-
memory.push(event);
|
|
1152
|
-
this.#memoryEvents.set(event.operationId, memory);
|
|
1153
|
-
return;
|
|
1154
|
-
}
|
|
1155
|
-
const path = join(this.#worldRoot(worldId), "events", `${String(event.sequence).padStart(12, "0")}.json`);
|
|
1156
|
-
await mkdir(join(this.#worldRoot(worldId), "events"), {
|
|
1157
|
-
recursive: true,
|
|
1158
|
-
mode: 0o700,
|
|
1159
|
-
});
|
|
1160
|
-
await writeDurableJson(path, event, true);
|
|
1161
|
-
}
|
|
1162
|
-
async #appendEventOnce(event) {
|
|
1163
|
-
const worldId = await this.#worldForOperation(event.operationId);
|
|
1164
|
-
if (worldId !== null) {
|
|
1165
|
-
const existing = await this.#events(worldId);
|
|
1166
|
-
if (existing.some((candidate) => sameEventIdentity(candidate, event)))
|
|
1167
|
-
return;
|
|
1168
|
-
}
|
|
1169
|
-
else {
|
|
1170
|
-
const existing = this.#memoryEvents.get(event.operationId) ?? [];
|
|
1171
|
-
if (existing.some((candidate) => sameEventIdentity(candidate, event)))
|
|
1172
|
-
return;
|
|
1173
|
-
}
|
|
1174
|
-
await this.#appendEvent(event);
|
|
1175
|
-
}
|
|
1176
|
-
async #effectiveRecords(worldId) {
|
|
1177
|
-
const raw = [];
|
|
1178
|
-
const memory = this.#memoryRecords;
|
|
1179
|
-
const recordsDirectory = join(this.#worldRoot(worldId), "records");
|
|
1180
|
-
try {
|
|
1181
|
-
const names = (await readdir(recordsDirectory))
|
|
1182
|
-
.filter((name) => name.endsWith(".json"))
|
|
1183
|
-
.sort();
|
|
1184
|
-
for (const name of names) {
|
|
1185
|
-
const parsed = parseArtifactJson(await readFile(join(recordsDirectory, name), "utf8"), "artifact raw record");
|
|
1186
|
-
assertRawRecord(parsed);
|
|
1187
|
-
if (name !== artifactSequenceFileName(parsed.sequence))
|
|
1188
|
-
throw new ArtifactStoreCorruptionError("Artifact raw-record file name does not match its sequence");
|
|
1189
|
-
if (parsed.worldId !== worldId)
|
|
1190
|
-
throw new ArtifactStoreCorruptionError("Artifact raw record does not match the world path");
|
|
1191
|
-
raw.push(parsed);
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
catch (error) {
|
|
1195
|
-
if (!isMissing(error))
|
|
1196
|
-
throw error;
|
|
1197
|
-
}
|
|
1198
|
-
for (const records of memory.values())
|
|
1199
|
-
for (const record of records)
|
|
1200
|
-
if (record.worldId === worldId)
|
|
1201
|
-
raw.push(record);
|
|
687
|
+
/** Records and operations of a world, read from its directory; memory-only records are included. */
|
|
688
|
+
async #records(worldId) {
|
|
689
|
+
const raw = [
|
|
690
|
+
...(await readRecordFiles(this.#artifactsRoot(worldId))),
|
|
691
|
+
...[...this.#memoryRecords.values()]
|
|
692
|
+
.flat()
|
|
693
|
+
.filter((record) => record.worldId === worldId),
|
|
694
|
+
];
|
|
1202
695
|
raw.sort((left, right) => left.sequence - right.sequence);
|
|
1203
696
|
const recordIds = new Set();
|
|
1204
697
|
const sequences = new Set();
|
|
698
|
+
const operations = new Map((await this.#operationsForWorld(worldId)).map((operation) => [
|
|
699
|
+
operation.context.operationId,
|
|
700
|
+
operation,
|
|
701
|
+
]));
|
|
1205
702
|
for (const record of raw) {
|
|
1206
703
|
if (recordIds.has(record.recordId))
|
|
1207
704
|
throw new ArtifactStoreCorruptionError("Artifact raw-record ID is duplicated");
|
|
@@ -1209,15 +706,7 @@ export class FileNativeArtifactStore {
|
|
|
1209
706
|
throw new ArtifactStoreCorruptionError("Artifact world sequence is duplicated");
|
|
1210
707
|
recordIds.add(record.recordId);
|
|
1211
708
|
sequences.add(record.sequence);
|
|
1212
|
-
|
|
1213
|
-
const state = new Map();
|
|
1214
|
-
for (const record of raw)
|
|
1215
|
-
state.set(record.recordId, { status: "pending", head: null });
|
|
1216
|
-
const operationStates = new Map();
|
|
1217
|
-
for (const operation of await this.#operationsForWorld(worldId))
|
|
1218
|
-
operationStates.set(operation.context.operationId, operation);
|
|
1219
|
-
for (const record of raw) {
|
|
1220
|
-
const operation = operationStates.get(record.operationId);
|
|
709
|
+
const operation = operations.get(record.operationId);
|
|
1221
710
|
if (operation?.context.worldId !== record.worldId ||
|
|
1222
711
|
operation.context.parentHead !== record.parentHead ||
|
|
1223
712
|
operation.context.playPresetId !== record.playPresetId ||
|
|
@@ -1226,177 +715,72 @@ export class FileNativeArtifactStore {
|
|
|
1226
715
|
record.playPresetScriptsEnabled)
|
|
1227
716
|
throw new ArtifactStoreCorruptionError("Artifact raw record does not match the operation identity");
|
|
1228
717
|
}
|
|
1229
|
-
|
|
1230
|
-
for (const event of events) {
|
|
1231
|
-
if (sequences.has(event.sequence))
|
|
1232
|
-
throw new ArtifactStoreCorruptionError("Artifact world sequence is duplicated");
|
|
1233
|
-
sequences.add(event.sequence);
|
|
1234
|
-
const eventOperation = operationStates.get(event.operationId);
|
|
1235
|
-
if (eventOperation === undefined)
|
|
1236
|
-
throw new ArtifactStoreCorruptionError("Artifact projection-event operation does not exist");
|
|
1237
|
-
if (event.recordId !== undefined) {
|
|
1238
|
-
const record = raw.find(({ recordId }) => recordId === event.recordId);
|
|
1239
|
-
if (record?.operationId !== event.operationId)
|
|
1240
|
-
throw new ArtifactStoreCorruptionError("Artifact projection event does not match the record identity");
|
|
1241
|
-
}
|
|
1242
|
-
if (event.kind === "activate" && event.recordId !== undefined) {
|
|
1243
|
-
const current = state.get(event.recordId);
|
|
1244
|
-
if (current?.status === "pending")
|
|
1245
|
-
current.status = "active";
|
|
1246
|
-
}
|
|
1247
|
-
else if (event.kind === "supersede" && event.recordId !== undefined) {
|
|
1248
|
-
const current = state.get(event.recordId);
|
|
1249
|
-
if (current !== undefined)
|
|
1250
|
-
current.status = "superseded";
|
|
1251
|
-
}
|
|
1252
|
-
else if (event.kind === "bind_head" && event.recordId !== undefined) {
|
|
1253
|
-
if ((eventOperation.status !== "core_committed" &&
|
|
1254
|
-
eventOperation.status !== "completed") ||
|
|
1255
|
-
eventOperation.head !== event.head)
|
|
1256
|
-
throw new ArtifactStoreCorruptionError("Artifact bind_head event does not match the operation core head");
|
|
1257
|
-
const current = state.get(event.recordId);
|
|
1258
|
-
if (current !== undefined)
|
|
1259
|
-
current.head = event.head ?? null;
|
|
1260
|
-
}
|
|
1261
|
-
else if (event.kind === "clear") {
|
|
1262
|
-
const initiator = operationStates.get(event.operationId);
|
|
1263
|
-
if (initiator === undefined ||
|
|
1264
|
-
initiator.artifactProjectionStatus === "superseded" ||
|
|
1265
|
-
(initiator.requestAttempts[event.requestId] !== undefined &&
|
|
1266
|
-
initiator.requestAttempts[event.requestId] !==
|
|
1267
|
-
event.requestAttempt))
|
|
1268
|
-
continue;
|
|
1269
|
-
for (const record of raw) {
|
|
1270
|
-
const current = state.get(record.recordId);
|
|
1271
|
-
if (current !== undefined &&
|
|
1272
|
-
current.status !== "superseded" &&
|
|
1273
|
-
current.status !== "cleared" &&
|
|
1274
|
-
record.sequence < event.sequence &&
|
|
1275
|
-
record.channel === event.channel &&
|
|
1276
|
-
record.output === event.output &&
|
|
1277
|
-
(event.key === undefined || record.key === event.key))
|
|
1278
|
-
current.status = "cleared";
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
|
-
return raw.map((record) => {
|
|
1283
|
-
const current = state.get(record.recordId);
|
|
1284
|
-
let status = current?.status ?? "failed";
|
|
1285
|
-
const operation = operationStates.get(record.operationId);
|
|
1286
|
-
const currentAttempt = operation?.requestAttempts[record.requestId];
|
|
1287
|
-
if (currentAttempt !== undefined &&
|
|
1288
|
-
currentAttempt !== record.requestAttempt &&
|
|
1289
|
-
status !== "failed")
|
|
1290
|
-
status = "superseded";
|
|
1291
|
-
if (operation?.artifactProjectionStatus === "superseded" &&
|
|
1292
|
-
status !== "failed")
|
|
1293
|
-
status = "superseded";
|
|
1294
|
-
return {
|
|
1295
|
-
record,
|
|
1296
|
-
status,
|
|
1297
|
-
head: current?.head ?? null,
|
|
1298
|
-
};
|
|
1299
|
-
});
|
|
718
|
+
return raw;
|
|
1300
719
|
}
|
|
1301
|
-
async #
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
if (operation?.context.worldId === worldId)
|
|
1323
|
-
result.push(...events);
|
|
720
|
+
async #recordsForOperation(worldId, operationId) {
|
|
721
|
+
return (await this.#records(worldId)).filter((record) => record.operationId === operationId);
|
|
722
|
+
}
|
|
723
|
+
/** Writes or replaces one record; save:none records stay in memory only. */
|
|
724
|
+
async #putRecord(input) {
|
|
725
|
+
// Only a cleared record keeps the clear that made it so.
|
|
726
|
+
const record = input.status === "cleared" ? input : withoutClearedBy(input);
|
|
727
|
+
const finalized = {
|
|
728
|
+
...record,
|
|
729
|
+
recordFingerprint: rawRecordFingerprint(record),
|
|
730
|
+
};
|
|
731
|
+
assertRawRecord(finalized);
|
|
732
|
+
if (record.save === "none") {
|
|
733
|
+
const records = this.#memoryRecords.get(record.operationId) ?? [];
|
|
734
|
+
const index = records.findIndex((candidate) => candidate.recordId === record.recordId);
|
|
735
|
+
if (index < 0)
|
|
736
|
+
records.push(finalized);
|
|
737
|
+
else
|
|
738
|
+
records[index] = finalized;
|
|
739
|
+
this.#memoryRecords.set(record.operationId, records);
|
|
740
|
+
return;
|
|
1324
741
|
}
|
|
1325
|
-
|
|
742
|
+
await writeArtifactJson(join(this.#artifactsRoot(record.worldId), "records", artifactSequenceFileName(record.sequence)), finalized);
|
|
1326
743
|
}
|
|
1327
|
-
async #
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
.map(({ record }) => record);
|
|
744
|
+
async #removeRecord(record) {
|
|
745
|
+
if (record.save === "none") {
|
|
746
|
+
const records = (this.#memoryRecords.get(record.operationId) ?? []).filter((candidate) => candidate.recordId !== record.recordId);
|
|
747
|
+
this.#memoryRecords.set(record.operationId, records);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
await rm(join(this.#artifactsRoot(record.worldId), "records", artifactSequenceFileName(record.sequence)), { force: true });
|
|
1335
751
|
}
|
|
1336
752
|
async #operationsForWorld(worldId) {
|
|
1337
|
-
|
|
1338
|
-
|
|
753
|
+
return [
|
|
754
|
+
...(await readOperationFiles(this.#artifactsRoot(worldId))).values(),
|
|
755
|
+
].filter((operation) => operation.context.worldId === worldId);
|
|
756
|
+
}
|
|
757
|
+
async #nextSequence(worldId) {
|
|
758
|
+
let maximum = 0;
|
|
1339
759
|
try {
|
|
1340
|
-
const
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
throw new ArtifactStoreCorruptionError("Artifact
|
|
1348
|
-
|
|
1349
|
-
throw new ArtifactStoreCorruptionError("Artifact operation ID is duplicated");
|
|
1350
|
-
if (operation.context.worldId === worldId)
|
|
1351
|
-
byId.set(operation.context.operationId, structuredClone(operation));
|
|
760
|
+
for (const name of await readdir(join(this.#artifactsRoot(worldId), "records"))) {
|
|
761
|
+
if (!name.endsWith(".json"))
|
|
762
|
+
continue;
|
|
763
|
+
const value = Number.parseInt(name.slice(0, -5), 10);
|
|
764
|
+
if (!Number.isSafeInteger(value) ||
|
|
765
|
+
value < 1 ||
|
|
766
|
+
name !== artifactSequenceFileName(value))
|
|
767
|
+
throw new ArtifactStoreCorruptionError("Artifact sequence file name is invalid");
|
|
768
|
+
maximum = Math.max(maximum, value);
|
|
1352
769
|
}
|
|
1353
770
|
}
|
|
1354
771
|
catch (error) {
|
|
1355
772
|
if (!isMissing(error))
|
|
1356
773
|
throw error;
|
|
1357
774
|
}
|
|
1358
|
-
return [...byId.values()];
|
|
1359
|
-
}
|
|
1360
|
-
async #nextSequence(worldId) {
|
|
1361
|
-
let maximum = 0;
|
|
1362
|
-
const directories = [
|
|
1363
|
-
join(this.#worldRoot(worldId), "records"),
|
|
1364
|
-
join(this.#worldRoot(worldId), "events"),
|
|
1365
|
-
];
|
|
1366
|
-
for (const directory of directories) {
|
|
1367
|
-
try {
|
|
1368
|
-
const names = (await readdir(directory)).filter((name) => name.endsWith(".json"));
|
|
1369
|
-
for (const name of names) {
|
|
1370
|
-
const value = Number.parseInt(name.slice(0, -5), 10);
|
|
1371
|
-
if (!Number.isSafeInteger(value) ||
|
|
1372
|
-
value < 1 ||
|
|
1373
|
-
name !== artifactSequenceFileName(value))
|
|
1374
|
-
throw new ArtifactStoreCorruptionError("Artifact sequence file name is invalid");
|
|
1375
|
-
maximum = Math.max(maximum, value);
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
catch (error) {
|
|
1379
|
-
if (!isMissing(error))
|
|
1380
|
-
throw error;
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
775
|
for (const records of this.#memoryRecords.values())
|
|
1384
776
|
for (const record of records)
|
|
1385
777
|
if (record.worldId === worldId)
|
|
1386
778
|
maximum = Math.max(maximum, record.sequence);
|
|
1387
|
-
for (const [operationId, events] of this.#memoryEvents) {
|
|
1388
|
-
const operation = await this.#readOperation(operationId);
|
|
1389
|
-
if (operation?.context.worldId !== worldId)
|
|
1390
|
-
continue;
|
|
1391
|
-
for (const event of events)
|
|
1392
|
-
maximum = Math.max(maximum, event.sequence);
|
|
1393
|
-
}
|
|
1394
779
|
return maximum + 1;
|
|
1395
780
|
}
|
|
1396
|
-
async #readOperation(operationId) {
|
|
1397
|
-
const path = join(this.#root, "operations", `${identityHash(operationId)}.json`);
|
|
781
|
+
async #readOperation(worldId, operationId) {
|
|
1398
782
|
try {
|
|
1399
|
-
const operation = parseArtifactJson(await readFile(
|
|
783
|
+
const operation = parseArtifactJson(await readFile(this.#operationPath(worldId, operationId), "utf8"), "artifact operation");
|
|
1400
784
|
assertOperation(operation);
|
|
1401
785
|
if (operation.context.operationId !== operationId)
|
|
1402
786
|
throw new ArtifactStoreCorruptionError("Artifact operation does not match the request identity");
|
|
@@ -1410,276 +794,208 @@ export class FileNativeArtifactStore {
|
|
|
1410
794
|
}
|
|
1411
795
|
async #writeOperation(operation) {
|
|
1412
796
|
assertOperation(operation);
|
|
1413
|
-
|
|
1414
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
1415
|
-
const path = join(directory, `${identityHash(operation.context.operationId)}.json`);
|
|
1416
|
-
await writeDurableJson(path, operation, false);
|
|
797
|
+
await writeArtifactJson(this.#operationPath(operation.context.worldId, operation.context.operationId), operation);
|
|
1417
798
|
}
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
799
|
+
/** Reads of a world whose artifacts are still in the legacy layout migrate it first. */
|
|
800
|
+
async #ensureReadable(worldId) {
|
|
801
|
+
if (await hasLegacyArtifactState(this.#dataRoot, worldId))
|
|
802
|
+
await this.#withWorldMutation(worldId, () => Promise.resolve());
|
|
1421
803
|
}
|
|
1422
804
|
async #withWorldMutation(worldId, action) {
|
|
1423
805
|
const active = this.#mutationContext.getStore();
|
|
1424
806
|
if (active?.has(worldId) === true)
|
|
1425
807
|
return action();
|
|
1426
|
-
return
|
|
1427
|
-
await this.#
|
|
808
|
+
return worldQueueFor(this.#dataRoot).run(worldId, () => this.#mutationContext.run(new Set([...(active ?? []), worldId]), async () => {
|
|
809
|
+
await migrateLegacyArtifactStore(this.#dataRoot, worldId);
|
|
1428
810
|
return action();
|
|
1429
811
|
}));
|
|
1430
812
|
}
|
|
1431
|
-
#
|
|
1432
|
-
|
|
813
|
+
#artifactsRoot(worldId) {
|
|
814
|
+
assertWorldDirectoryId(worldId);
|
|
815
|
+
return join(this.#worldsRoot, worldId, "artifacts");
|
|
1433
816
|
}
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
return join(resolve(dataRoot), "runtime-locks", "artifacts", identityHash(worldId));
|
|
1437
|
-
}
|
|
1438
|
-
const artifactWorldLockStaleMilliseconds = 30_000;
|
|
1439
|
-
const artifactWorldLockWaitMilliseconds = 30_000;
|
|
1440
|
-
async function withArtifactWorldLock(path, action) {
|
|
1441
|
-
const token = randomUUID();
|
|
1442
|
-
const processIdentity = await artifactLocalProcessIdentity();
|
|
1443
|
-
const parent = dirname(path);
|
|
1444
|
-
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
1445
|
-
const deadline = Date.now() + artifactWorldLockWaitMilliseconds;
|
|
1446
|
-
while (true) {
|
|
1447
|
-
try {
|
|
1448
|
-
await mkdir(path, { mode: 0o700 });
|
|
1449
|
-
break;
|
|
1450
|
-
}
|
|
1451
|
-
catch (error) {
|
|
1452
|
-
if (!isAlreadyExists(error))
|
|
1453
|
-
throw error;
|
|
1454
|
-
const owner = await readArtifactLockOwner(path);
|
|
1455
|
-
if (owner !== null && (await artifactLockOwnerIsAlive(owner, path))) {
|
|
1456
|
-
if (Date.now() >= deadline)
|
|
1457
|
-
throw new Error("artifact world mutation lock is busy", {
|
|
1458
|
-
cause: error,
|
|
1459
|
-
});
|
|
1460
|
-
await artifactLockDelay(5);
|
|
1461
|
-
continue;
|
|
1462
|
-
}
|
|
1463
|
-
const metadata = await stat(path).catch(() => null);
|
|
1464
|
-
if (owner !== null ||
|
|
1465
|
-
(metadata !== null &&
|
|
1466
|
-
Date.now() - metadata.mtimeMs > artifactWorldLockStaleMilliseconds))
|
|
1467
|
-
await retireArtifactWorldLock(path, owner, metadata, parent);
|
|
1468
|
-
if (Date.now() >= deadline)
|
|
1469
|
-
throw new Error("artifact world mutation lock is busy", {
|
|
1470
|
-
cause: error,
|
|
1471
|
-
});
|
|
1472
|
-
await artifactLockDelay(5);
|
|
1473
|
-
}
|
|
817
|
+
#operationPath(worldId, operationId) {
|
|
818
|
+
return join(this.#artifactsRoot(worldId), "operations", `${identityHash(operationId)}.json`);
|
|
1474
819
|
}
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Rewrites a copied world directory into a fork target: records and operations
|
|
823
|
+
* are kept only when they are committed, active, attached to a reply and bound
|
|
824
|
+
* at or before the restored head. Everything else is deleted file by file, and
|
|
825
|
+
* the survivors take the target identity. Runs on an unpublished staging copy.
|
|
826
|
+
*/
|
|
827
|
+
export async function forkArtifactClosure(worldRoot, input) {
|
|
828
|
+
const artifactsRoot = join(worldRoot, "artifacts");
|
|
829
|
+
const limit = headOrdinal(input.restoredHead);
|
|
830
|
+
const records = await readRecordFiles(artifactsRoot);
|
|
831
|
+
const operations = await readOperationFiles(artifactsRoot);
|
|
832
|
+
for (const record of records)
|
|
833
|
+
if (record.worldId !== input.sourceWorldId)
|
|
834
|
+
throw new ArtifactStoreCorruptionError("Artifact fork record belongs to another world");
|
|
835
|
+
// A fork keeps what the source's projection would show at that head: the
|
|
836
|
+
// committed, attached, selected records. Superseded or unselected records
|
|
837
|
+
// stay behind with the source.
|
|
838
|
+
const candidates = records.filter((record) => {
|
|
839
|
+
const operation = operations.get(record.operationId);
|
|
840
|
+
return (record.save === "commit" &&
|
|
841
|
+
record.status === "active" &&
|
|
842
|
+
record.projection !== "hidden" &&
|
|
843
|
+
(record.projection !== "transient" || isActiveInteraction(operation)) &&
|
|
844
|
+
operation !== undefined &&
|
|
845
|
+
operation.artifactProjectionStatus !== "superseded" &&
|
|
846
|
+
operation.context.attachment !== undefined &&
|
|
847
|
+
headOrdinal(record.head ?? record.parentHead) <= limit);
|
|
848
|
+
});
|
|
849
|
+
const retained = selectProjection(candidates);
|
|
850
|
+
const retainedRecordIds = new Set(retained.map((record) => record.recordId));
|
|
851
|
+
for (const record of records)
|
|
852
|
+
if (!retainedRecordIds.has(record.recordId))
|
|
853
|
+
await rm(join(artifactsRoot, "records", artifactSequenceFileName(record.sequence)), { force: true });
|
|
854
|
+
const retainedOperations = new Set(retained.map((record) => record.operationId));
|
|
855
|
+
for (const operationId of operations.keys())
|
|
856
|
+
if (!retainedOperations.has(operationId))
|
|
857
|
+
await rm(join(artifactsRoot, "operations", `${identityHash(operationId)}.json`), {
|
|
858
|
+
force: true,
|
|
1493
859
|
});
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
throw new Error("simulated artifact lock setup failure");
|
|
1502
|
-
await writeDurableJson(heartbeatPath, { token }, true);
|
|
1503
|
-
heartbeatHandle = await open(heartbeatPath, "r+");
|
|
1504
|
-
schedule();
|
|
1505
|
-
return await action();
|
|
860
|
+
for (const record of retained) {
|
|
861
|
+
const next = {
|
|
862
|
+
...record,
|
|
863
|
+
worldId: input.targetWorldId,
|
|
864
|
+
operationId: forkOperationId(input.targetWorldId, record.operationId),
|
|
865
|
+
};
|
|
866
|
+
await writeArtifactJson(join(artifactsRoot, "records", artifactSequenceFileName(record.sequence)), { ...next, recordFingerprint: rawRecordFingerprint(next) });
|
|
1506
867
|
}
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
868
|
+
for (const operationId of retainedOperations) {
|
|
869
|
+
const operation = operations.get(operationId);
|
|
870
|
+
const nextId = forkOperationId(input.targetWorldId, operationId);
|
|
871
|
+
const next = {
|
|
872
|
+
...operation,
|
|
873
|
+
context: {
|
|
874
|
+
...operation.context,
|
|
875
|
+
worldId: input.targetWorldId,
|
|
876
|
+
operationId: nextId,
|
|
877
|
+
},
|
|
878
|
+
};
|
|
879
|
+
await writeArtifactJson(join(artifactsRoot, "operations", `${identityHash(nextId)}.json`), next);
|
|
880
|
+
await rm(join(artifactsRoot, "operations", `${identityHash(operationId)}.json`), {
|
|
881
|
+
force: true,
|
|
882
|
+
});
|
|
1514
883
|
}
|
|
1515
884
|
}
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
}
|
|
1521
|
-
catch (error) {
|
|
1522
|
-
if (isMissing(error))
|
|
1523
|
-
return null;
|
|
1524
|
-
throw error;
|
|
1525
|
-
}
|
|
1526
|
-
if (!isRecord(value) ||
|
|
1527
|
-
!hasExactKeys(value, ["token", "pid", "processIdentity"]) ||
|
|
1528
|
-
!isNonEmptyString(value.token) ||
|
|
1529
|
-
!Number.isSafeInteger(value.pid) ||
|
|
1530
|
-
Number(value.pid) < 1 ||
|
|
1531
|
-
!isNonEmptyString(value.processIdentity))
|
|
1532
|
-
throw new ArtifactStoreCorruptionError("Artifact world-mutation lock owner is corrupt");
|
|
1533
|
-
return value;
|
|
885
|
+
function withoutClearedBy(record) {
|
|
886
|
+
const copy = { ...record };
|
|
887
|
+
delete copy.clearedBy;
|
|
888
|
+
return copy;
|
|
1534
889
|
}
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
return;
|
|
1538
|
-
const currentOwner = await readArtifactLockOwner(path);
|
|
1539
|
-
const currentMetadata = await stat(path).catch(() => null);
|
|
1540
|
-
const sameGeneration = observedOwner === null
|
|
1541
|
-
? currentOwner === null &&
|
|
1542
|
-
observedMetadata !== null &&
|
|
1543
|
-
currentMetadata !== null &&
|
|
1544
|
-
observedMetadata.dev === currentMetadata.dev &&
|
|
1545
|
-
observedMetadata.ino === currentMetadata.ino
|
|
1546
|
-
: currentOwner?.token === observedOwner.token;
|
|
1547
|
-
if (!sameGeneration)
|
|
1548
|
-
return;
|
|
1549
|
-
if (currentOwner !== null &&
|
|
1550
|
-
(await artifactLockOwnerIsAlive(currentOwner, path)))
|
|
1551
|
-
return;
|
|
1552
|
-
if (currentOwner === null &&
|
|
1553
|
-
(currentMetadata === null ||
|
|
1554
|
-
Date.now() - currentMetadata.mtimeMs <=
|
|
1555
|
-
artifactWorldLockStaleMilliseconds))
|
|
1556
|
-
return;
|
|
1557
|
-
const generation = currentOwner?.token ??
|
|
1558
|
-
`inode-${currentMetadata?.dev ?? "missing"}-${currentMetadata?.ino ?? "missing"}`;
|
|
1559
|
-
const retired = `${path}.${identityHash(generation)}.${randomUUID()}.retired`;
|
|
1560
|
-
try {
|
|
1561
|
-
await rename(path, retired);
|
|
1562
|
-
await syncArtifactDirectory(locksRoot);
|
|
1563
|
-
await rm(retired, { recursive: true, force: true });
|
|
1564
|
-
}
|
|
1565
|
-
catch (error) {
|
|
1566
|
-
if (isMissing(error) || isAlreadyExists(error))
|
|
1567
|
-
return;
|
|
1568
|
-
throw error;
|
|
1569
|
-
}
|
|
890
|
+
function forkOperationId(targetWorldId, operationId) {
|
|
891
|
+
return `artifact-fork:${targetWorldId}:${identityHash(operationId)}`;
|
|
1570
892
|
}
|
|
1571
|
-
async function
|
|
1572
|
-
const
|
|
1573
|
-
|
|
1574
|
-
return;
|
|
1575
|
-
const retired = `${path}.${identityHash(token)}.${randomUUID()}.retired`;
|
|
893
|
+
async function readRecordFiles(artifactsRoot) {
|
|
894
|
+
const directory = join(artifactsRoot, "records");
|
|
895
|
+
let names;
|
|
1576
896
|
try {
|
|
1577
|
-
await
|
|
1578
|
-
await syncArtifactDirectory(locksRoot);
|
|
1579
|
-
await rm(retired, { recursive: true, force: true });
|
|
897
|
+
names = (await readdir(directory)).filter((name) => name.endsWith(".json"));
|
|
1580
898
|
}
|
|
1581
899
|
catch (error) {
|
|
1582
|
-
if (isMissing(error)
|
|
1583
|
-
return;
|
|
900
|
+
if (isMissing(error))
|
|
901
|
+
return [];
|
|
1584
902
|
throw error;
|
|
1585
903
|
}
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
const identity = await artifactProcessIdentity(owner.pid);
|
|
1594
|
-
if (identity === owner.processIdentity)
|
|
1595
|
-
return true;
|
|
1596
|
-
if (identity === null) {
|
|
1597
|
-
try {
|
|
1598
|
-
process.kill(owner.pid, 0);
|
|
1599
|
-
return true;
|
|
1600
|
-
}
|
|
1601
|
-
catch (error) {
|
|
1602
|
-
if (isRecord(error) && error.code === "ESRCH")
|
|
1603
|
-
return false;
|
|
1604
|
-
// Fall through to the heartbeat for non-Linux or inaccessible processes.
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
const heartbeat = await stat(join(path, "heartbeat")).catch(() => null);
|
|
1608
|
-
return (heartbeat !== null &&
|
|
1609
|
-
Date.now() - heartbeat.mtimeMs <= artifactWorldLockStaleMilliseconds);
|
|
1610
|
-
}
|
|
1611
|
-
async function artifactProcessIdentity(pid) {
|
|
1612
|
-
try {
|
|
1613
|
-
const [bootId, namespace, processStat] = await Promise.all([
|
|
1614
|
-
readFile("/proc/sys/kernel/random/boot_id", "utf8"),
|
|
1615
|
-
readlink(`/proc/${pid}/ns/pid`),
|
|
1616
|
-
readFile(`/proc/${pid}/stat`, "utf8"),
|
|
1617
|
-
]);
|
|
1618
|
-
const commandEnd = processStat.lastIndexOf(") ");
|
|
1619
|
-
if (commandEnd < 0)
|
|
1620
|
-
return null;
|
|
1621
|
-
const fields = processStat
|
|
1622
|
-
.slice(commandEnd + 2)
|
|
1623
|
-
.trim()
|
|
1624
|
-
.split(/\s+/u);
|
|
1625
|
-
const startTicks = fields[19];
|
|
1626
|
-
if (startTicks === undefined || !/^\d+$/u.test(startTicks))
|
|
1627
|
-
return null;
|
|
1628
|
-
return `linux:${bootId.trim()}:${namespace}:${startTicks}`;
|
|
1629
|
-
}
|
|
1630
|
-
catch {
|
|
1631
|
-
return null;
|
|
904
|
+
const records = [];
|
|
905
|
+
for (const name of names.sort()) {
|
|
906
|
+
const parsed = parseArtifactJson(await readFile(join(directory, name), "utf8"), "artifact raw record");
|
|
907
|
+
assertRawRecord(parsed);
|
|
908
|
+
if (name !== artifactSequenceFileName(parsed.sequence))
|
|
909
|
+
throw new ArtifactStoreCorruptionError("Artifact raw-record file name does not match its sequence");
|
|
910
|
+
records.push(parsed);
|
|
1632
911
|
}
|
|
912
|
+
return records.sort((left, right) => left.sequence - right.sequence);
|
|
1633
913
|
}
|
|
1634
|
-
async function
|
|
1635
|
-
const directory =
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
let handle;
|
|
1639
|
-
let created = false;
|
|
914
|
+
async function readOperationFiles(artifactsRoot) {
|
|
915
|
+
const directory = join(artifactsRoot, "operations");
|
|
916
|
+
const byId = new Map();
|
|
917
|
+
let names;
|
|
1640
918
|
try {
|
|
1641
|
-
|
|
1642
|
-
created = true;
|
|
1643
|
-
await handle.writeFile(JSON.stringify(value), "utf8");
|
|
1644
|
-
await handle.sync();
|
|
1645
|
-
await handle.close();
|
|
1646
|
-
handle = undefined;
|
|
1647
|
-
if (!exclusive)
|
|
1648
|
-
await rename(target, path);
|
|
1649
|
-
await syncArtifactDirectory(directory);
|
|
919
|
+
names = (await readdir(directory)).filter((name) => name.endsWith(".json"));
|
|
1650
920
|
}
|
|
1651
921
|
catch (error) {
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
await rm(target, { force: true }).catch(() => undefined);
|
|
922
|
+
if (isMissing(error))
|
|
923
|
+
return byId;
|
|
1655
924
|
throw error;
|
|
1656
925
|
}
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
throw new ArtifactStoreCorruptionError(`${label} JSON is corrupt`, error instanceof Error ? { cause: error } : undefined);
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
async function syncArtifactDirectory(path) {
|
|
1667
|
-
const handle = await open(path, "r");
|
|
1668
|
-
try {
|
|
1669
|
-
await handle.sync();
|
|
1670
|
-
}
|
|
1671
|
-
finally {
|
|
1672
|
-
await handle.close();
|
|
926
|
+
for (const name of names.sort()) {
|
|
927
|
+
const operation = parseArtifactJson(await readFile(join(directory, name), "utf8"), "artifact operation");
|
|
928
|
+
assertOperation(operation);
|
|
929
|
+
if (name !== `${identityHash(operation.context.operationId)}.json`)
|
|
930
|
+
throw new ArtifactStoreCorruptionError("Artifact operation does not match the file-path identity");
|
|
931
|
+
byId.set(operation.context.operationId, operation);
|
|
1673
932
|
}
|
|
933
|
+
return byId;
|
|
1674
934
|
}
|
|
1675
|
-
function
|
|
1676
|
-
|
|
935
|
+
function assertWorldDirectoryId(worldId) {
|
|
936
|
+
if (worldId === "" ||
|
|
937
|
+
worldId === "." ||
|
|
938
|
+
worldId === ".." ||
|
|
939
|
+
worldId.includes("/") ||
|
|
940
|
+
worldId.includes("\\"))
|
|
941
|
+
throw new ArtifactStoreInvariantError("Artifact world ID is not a path");
|
|
1677
942
|
}
|
|
1678
943
|
export class InMemoryArtifactStore extends FileNativeArtifactStore {
|
|
1679
944
|
constructor() {
|
|
1680
945
|
super(join(tmpdir(), "narraeon-ephemeral-artifacts", randomUUID()));
|
|
1681
946
|
}
|
|
1682
947
|
}
|
|
948
|
+
function newRecord(input, declaration, payload, identity) {
|
|
949
|
+
const record = {
|
|
950
|
+
schemaVersion: 1,
|
|
951
|
+
recordId: `artifact:${identity.sequence}:${identity.fingerprint.slice(7, 23)}`,
|
|
952
|
+
sequence: identity.sequence,
|
|
953
|
+
worldId: input.context.worldId,
|
|
954
|
+
parentHead: input.context.parentHead,
|
|
955
|
+
operationId: input.context.operationId,
|
|
956
|
+
playPresetId: input.context.playPresetId,
|
|
957
|
+
playPresetRevision: input.context.playPresetRevision,
|
|
958
|
+
playPresetScriptsEnabled: input.context.playPresetScriptsEnabled,
|
|
959
|
+
requestId: input.context.requestId,
|
|
960
|
+
requestAttempt: input.context.requestAttempt,
|
|
961
|
+
output: declaration.name,
|
|
962
|
+
channel: declaration.channel,
|
|
963
|
+
...(declaration.key === undefined ? {} : { key: declaration.key }),
|
|
964
|
+
contentType: declaration.contentType,
|
|
965
|
+
...(declaration.renderer === undefined
|
|
966
|
+
? {}
|
|
967
|
+
: { renderer: declaration.renderer }),
|
|
968
|
+
...(declaration.rendererRevision === undefined
|
|
969
|
+
? {}
|
|
970
|
+
: { rendererRevision: declaration.rendererRevision }),
|
|
971
|
+
...(input.context.extensionControl === undefined
|
|
972
|
+
? {}
|
|
973
|
+
: {
|
|
974
|
+
extensionControl: structuredClone(input.context.extensionControl),
|
|
975
|
+
}),
|
|
976
|
+
...(input.context.frozenPresentations?.[declaration.name] !== undefined
|
|
977
|
+
? {
|
|
978
|
+
frozenPresentation: structuredClone(input.context.frozenPresentations[declaration.name]),
|
|
979
|
+
}
|
|
980
|
+
: input.context.frozenResources === undefined
|
|
981
|
+
? {}
|
|
982
|
+
: {
|
|
983
|
+
frozenPresentation: {
|
|
984
|
+
...structuredClone(input.context.frozenResources),
|
|
985
|
+
declaration: structuredClone(declaration),
|
|
986
|
+
},
|
|
987
|
+
}),
|
|
988
|
+
payload: structuredClone(payload),
|
|
989
|
+
save: declaration.save,
|
|
990
|
+
projection: declaration.strategy,
|
|
991
|
+
invalidation: declaration.invalidation,
|
|
992
|
+
toolCallId: input.toolCallId,
|
|
993
|
+
toolName: "artifact_emit",
|
|
994
|
+
callFingerprint: identity.callFingerprint,
|
|
995
|
+
payloadFingerprint: identity.fingerprint,
|
|
996
|
+
};
|
|
997
|
+
return record;
|
|
998
|
+
}
|
|
1683
999
|
function findDeclaration(context, output) {
|
|
1684
1000
|
return context.declarations.find(({ name }) => name === output) ?? null;
|
|
1685
1001
|
}
|
|
@@ -1708,17 +1024,6 @@ function normalizePayload(declaration, input) {
|
|
|
1708
1024
|
return contract;
|
|
1709
1025
|
return { ok: true, value: structuredClone(input) };
|
|
1710
1026
|
}
|
|
1711
|
-
function isJsonValue(value) {
|
|
1712
|
-
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
1713
|
-
return true;
|
|
1714
|
-
if (typeof value === "number")
|
|
1715
|
-
return Number.isFinite(value);
|
|
1716
|
-
if (Array.isArray(value))
|
|
1717
|
-
return value.every(isJsonValue);
|
|
1718
|
-
if (typeof value !== "object")
|
|
1719
|
-
return false;
|
|
1720
|
-
return Object.values(value).every(isJsonValue);
|
|
1721
|
-
}
|
|
1722
1027
|
function byteLength(payload, contentType) {
|
|
1723
1028
|
return Buffer.byteLength(payloadText(payload, contentType), "utf8");
|
|
1724
1029
|
}
|
|
@@ -1727,9 +1032,6 @@ function payloadText(payload, contentType) {
|
|
|
1727
1032
|
return JSON.stringify(payload);
|
|
1728
1033
|
return typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
1729
1034
|
}
|
|
1730
|
-
function payloadFingerprint(output, payload) {
|
|
1731
|
-
return `sha256:${createHash("sha256").update(JSON.stringify({ output, payload })).digest("hex")}`;
|
|
1732
|
-
}
|
|
1733
1035
|
function artifactCallFingerprint(toolName, context, parameters) {
|
|
1734
1036
|
return `sha256:${createHash("sha256")
|
|
1735
1037
|
.update(stableSerialize({
|
|
@@ -1749,39 +1051,18 @@ function artifactCallFingerprint(toolName, context, parameters) {
|
|
|
1749
1051
|
}))
|
|
1750
1052
|
.digest("hex")}`;
|
|
1751
1053
|
}
|
|
1752
|
-
function stableSerialize(value) {
|
|
1753
|
-
if (value === null || typeof value !== "object")
|
|
1754
|
-
return JSON.stringify(value);
|
|
1755
|
-
if (Array.isArray(value))
|
|
1756
|
-
return `[${value.map((item) => stableSerialize(item)).join(",")}]`;
|
|
1757
|
-
return `{${Object.keys(value)
|
|
1758
|
-
.sort()
|
|
1759
|
-
.map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key])}`)
|
|
1760
|
-
.join(",")}}`;
|
|
1761
|
-
}
|
|
1762
|
-
function rawRecordFingerprint(record) {
|
|
1763
|
-
const content = { ...record };
|
|
1764
|
-
delete content.recordFingerprint;
|
|
1765
|
-
return `sha256:${createHash("sha256").update(JSON.stringify(content)).digest("hex")}`;
|
|
1766
|
-
}
|
|
1767
|
-
function sameEventIdentity(left, right) {
|
|
1768
|
-
return (left.kind === right.kind &&
|
|
1769
|
-
left.operationId === right.operationId &&
|
|
1770
|
-
left.recordId === right.recordId &&
|
|
1771
|
-
left.output === right.output &&
|
|
1772
|
-
left.channel === right.channel &&
|
|
1773
|
-
left.key === right.key &&
|
|
1774
|
-
left.head === right.head &&
|
|
1775
|
-
left.requestId === right.requestId &&
|
|
1776
|
-
left.requestAttempt === right.requestAttempt &&
|
|
1777
|
-
left.reason === right.reason &&
|
|
1778
|
-
left.toolCallId === right.toolCallId &&
|
|
1779
|
-
left.toolName === right.toolName &&
|
|
1780
|
-
left.callFingerprint === right.callFingerprint);
|
|
1781
|
-
}
|
|
1782
1054
|
function artifactFailure(message) {
|
|
1783
1055
|
return { ok: false, markdown: `# Runtime artifact rejected\n\n${message}` };
|
|
1784
1056
|
}
|
|
1057
|
+
function recoveryRequired(operationId, completedRequests, message) {
|
|
1058
|
+
return {
|
|
1059
|
+
operationId,
|
|
1060
|
+
status: "recovery_required",
|
|
1061
|
+
completedRequests,
|
|
1062
|
+
...(message === undefined ? {} : { message }),
|
|
1063
|
+
coreCommitted: false,
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1785
1066
|
function isOpenExtension(operation) {
|
|
1786
1067
|
return (operation.status === "core_committed" &&
|
|
1787
1068
|
["not_started", "running", "unknown", "recovery_required"].includes(operation.extensionStatus));
|
|
@@ -1816,12 +1097,6 @@ function isActiveInteraction(operation) {
|
|
|
1816
1097
|
(operation.status === "core_committed" &&
|
|
1817
1098
|
operation.extensionStatus === "running")));
|
|
1818
1099
|
}
|
|
1819
|
-
function identityHash(value) {
|
|
1820
|
-
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
1821
|
-
}
|
|
1822
|
-
function artifactSequenceFileName(sequence) {
|
|
1823
|
-
return `${String(sequence).padStart(12, "0")}.json`;
|
|
1824
|
-
}
|
|
1825
1100
|
function summary(operation) {
|
|
1826
1101
|
return {
|
|
1827
1102
|
...(operation.context.attachment === undefined
|
|
@@ -1834,9 +1109,7 @@ function summary(operation) {
|
|
|
1834
1109
|
status: operation.extensionStatus,
|
|
1835
1110
|
...(operation.message === undefined ? {} : { message: operation.message }),
|
|
1836
1111
|
completedRequests: [...operation.completedRequests],
|
|
1837
|
-
coreCommitted: operation.status === "
|
|
1838
|
-
operation.status === "core_committed" ||
|
|
1839
|
-
operation.status === "completed",
|
|
1112
|
+
coreCommitted: operation.status === "core_committed" || operation.status === "completed",
|
|
1840
1113
|
...(operation.head === undefined ? {} : { head: operation.head }),
|
|
1841
1114
|
};
|
|
1842
1115
|
}
|
|
@@ -1844,16 +1117,15 @@ function selectProjection(active) {
|
|
|
1844
1117
|
const append = [];
|
|
1845
1118
|
const replace = new Map();
|
|
1846
1119
|
const upsert = new Map();
|
|
1847
|
-
for (const
|
|
1848
|
-
if (
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
return [...append, ...replace.values(), ...upsert.values()].sort((left, right) => left.record.sequence - right.record.sequence);
|
|
1120
|
+
for (const record of [...active].sort((left, right) => left.sequence - right.sequence)) {
|
|
1121
|
+
if (record.projection === "append" || record.projection === "transient")
|
|
1122
|
+
append.push(record);
|
|
1123
|
+
else if (record.projection === "replace")
|
|
1124
|
+
replace.set(record.channel, record);
|
|
1125
|
+
else if (record.projection === "upsert")
|
|
1126
|
+
upsert.set(`${record.channel}\0${record.key ?? ""}`, record);
|
|
1127
|
+
}
|
|
1128
|
+
return [...append, ...replace.values(), ...upsert.values()].sort((left, right) => left.sequence - right.sequence);
|
|
1857
1129
|
}
|
|
1858
1130
|
function assertContext(context) {
|
|
1859
1131
|
if (!isRecord(context) ||
|
|
@@ -1914,315 +1186,3 @@ function sameContext(left, right) {
|
|
|
1914
1186
|
left.playPresetRevision === right.playPresetRevision &&
|
|
1915
1187
|
left.playPresetScriptsEnabled === right.playPresetScriptsEnabled);
|
|
1916
1188
|
}
|
|
1917
|
-
function assertRawRecord(record) {
|
|
1918
|
-
if (!isRecord(record) ||
|
|
1919
|
-
!hasExactKeys(record, [
|
|
1920
|
-
"schemaVersion",
|
|
1921
|
-
"recordId",
|
|
1922
|
-
"sequence",
|
|
1923
|
-
"worldId",
|
|
1924
|
-
"parentHead",
|
|
1925
|
-
"newHead",
|
|
1926
|
-
"operationId",
|
|
1927
|
-
"playPresetId",
|
|
1928
|
-
"playPresetRevision",
|
|
1929
|
-
"playPresetScriptsEnabled",
|
|
1930
|
-
"requestId",
|
|
1931
|
-
"requestAttempt",
|
|
1932
|
-
"output",
|
|
1933
|
-
"channel",
|
|
1934
|
-
"contentType",
|
|
1935
|
-
"payload",
|
|
1936
|
-
"save",
|
|
1937
|
-
"projection",
|
|
1938
|
-
"invalidation",
|
|
1939
|
-
"toolCallId",
|
|
1940
|
-
"toolName",
|
|
1941
|
-
"callFingerprint",
|
|
1942
|
-
"payloadFingerprint",
|
|
1943
|
-
"recordFingerprint",
|
|
1944
|
-
"status",
|
|
1945
|
-
], [
|
|
1946
|
-
"key",
|
|
1947
|
-
"renderer",
|
|
1948
|
-
"rendererRevision",
|
|
1949
|
-
"frozenPresentation",
|
|
1950
|
-
"extensionControl",
|
|
1951
|
-
]) ||
|
|
1952
|
-
(record.extensionControl !== undefined &&
|
|
1953
|
-
!isWorldExtensionControl(record.extensionControl)) ||
|
|
1954
|
-
(record.frozenPresentation !== undefined &&
|
|
1955
|
-
!isFrozenArtifactPresentation(record.frozenPresentation)) ||
|
|
1956
|
-
record.schemaVersion !== 1 ||
|
|
1957
|
-
!isNonEmptyString(record.recordId) ||
|
|
1958
|
-
!Number.isSafeInteger(record.sequence) ||
|
|
1959
|
-
record.sequence < 1 ||
|
|
1960
|
-
!isNonEmptyString(record.worldId) ||
|
|
1961
|
-
!isNonEmptyString(record.operationId) ||
|
|
1962
|
-
!isNonEmptyString(record.output) ||
|
|
1963
|
-
!isNonEmptyString(record.channel) ||
|
|
1964
|
-
!isNonEmptyString(record.parentHead) ||
|
|
1965
|
-
!isNonEmptyString(record.playPresetId) ||
|
|
1966
|
-
!isNonEmptyString(record.playPresetRevision) ||
|
|
1967
|
-
!isNonEmptyString(record.requestId) ||
|
|
1968
|
-
!Number.isSafeInteger(record.requestAttempt) ||
|
|
1969
|
-
record.requestAttempt < 1 ||
|
|
1970
|
-
record.newHead !== null ||
|
|
1971
|
-
typeof record.playPresetScriptsEnabled !== "boolean" ||
|
|
1972
|
-
!["text/plain", "text/markdown", "application/json", "text/html"].includes(record.contentType) ||
|
|
1973
|
-
!["none", "operation", "commit"].includes(record.save) ||
|
|
1974
|
-
!["append", "replace", "upsert", "transient", "hidden"].includes(record.projection) ||
|
|
1975
|
-
![
|
|
1976
|
-
"new_operation",
|
|
1977
|
-
"head_change",
|
|
1978
|
-
"operation_end",
|
|
1979
|
-
"explicit_clear",
|
|
1980
|
-
"never",
|
|
1981
|
-
].includes(record.invalidation) ||
|
|
1982
|
-
typeof record.toolCallId !== "string" ||
|
|
1983
|
-
record.toolName !== "artifact_emit" ||
|
|
1984
|
-
!isSha256(record.callFingerprint) ||
|
|
1985
|
-
!isSha256(record.payloadFingerprint) ||
|
|
1986
|
-
!isSha256(record.recordFingerprint) ||
|
|
1987
|
-
(record.renderer === undefined) !==
|
|
1988
|
-
(record.rendererRevision === undefined) ||
|
|
1989
|
-
(record.renderer !== undefined && !isNonEmptyString(record.renderer)) ||
|
|
1990
|
-
(record.rendererRevision !== undefined &&
|
|
1991
|
-
!isNonEmptyString(record.rendererRevision)) ||
|
|
1992
|
-
(record.projection === "upsert"
|
|
1993
|
-
? !isNonEmptyString(record.key)
|
|
1994
|
-
: record.key !== undefined) ||
|
|
1995
|
-
record.status !== "pending")
|
|
1996
|
-
throw new ArtifactStoreCorruptionError("Artifact raw record is corrupt");
|
|
1997
|
-
if (!isJsonValue(record.payload))
|
|
1998
|
-
throw new ArtifactStoreCorruptionError("Artifact payload is not a JSON value");
|
|
1999
|
-
if (record.payloadFingerprint !==
|
|
2000
|
-
payloadFingerprint(record.output, record.payload))
|
|
2001
|
-
throw new ArtifactStoreCorruptionError("Artifact payload fingerprint does not match");
|
|
2002
|
-
if (record.recordFingerprint !== rawRecordFingerprint(record))
|
|
2003
|
-
throw new ArtifactStoreCorruptionError("Artifact raw-record fingerprint does not match");
|
|
2004
|
-
}
|
|
2005
|
-
function assertOperation(operation) {
|
|
2006
|
-
const context = operation.context;
|
|
2007
|
-
if (!isRecord(operation) ||
|
|
2008
|
-
!hasExactKeys(operation, [
|
|
2009
|
-
"schemaVersion",
|
|
2010
|
-
"context",
|
|
2011
|
-
"status",
|
|
2012
|
-
"extensionStatus",
|
|
2013
|
-
"completedRequests",
|
|
2014
|
-
"artifactProjectionStatus",
|
|
2015
|
-
"requestAttempts",
|
|
2016
|
-
], ["message", "head", "requests"]) ||
|
|
2017
|
-
(operation.requests !== undefined &&
|
|
2018
|
-
(!Array.isArray(operation.requests) ||
|
|
2019
|
-
!operation.requests.every((item) => isRecord(item) &&
|
|
2020
|
-
hasExactKeys(item, [
|
|
2021
|
-
"requestId",
|
|
2022
|
-
"displayName",
|
|
2023
|
-
"mounts",
|
|
2024
|
-
"status",
|
|
2025
|
-
]) &&
|
|
2026
|
-
isNonEmptyString(item.requestId) &&
|
|
2027
|
-
isNonEmptyString(item.displayName) &&
|
|
2028
|
-
Array.isArray(item.mounts) &&
|
|
2029
|
-
item.mounts.every((mount) => typeof mount === "string" &&
|
|
2030
|
-
[
|
|
2031
|
-
"story",
|
|
2032
|
-
"sidebar",
|
|
2033
|
-
"composer_above",
|
|
2034
|
-
"composer_below",
|
|
2035
|
-
"overlay",
|
|
2036
|
-
"debug",
|
|
2037
|
-
].includes(mount)) &&
|
|
2038
|
-
["running", "completed", "failed"].includes(item.status)))) ||
|
|
2039
|
-
operation.schemaVersion !== 1 ||
|
|
2040
|
-
!isRecord(context) ||
|
|
2041
|
-
!hasExactKeys(context, [
|
|
2042
|
-
"worldId",
|
|
2043
|
-
"parentHead",
|
|
2044
|
-
"operationId",
|
|
2045
|
-
"playPresetId",
|
|
2046
|
-
"playPresetRevision",
|
|
2047
|
-
"playPresetScriptsEnabled",
|
|
2048
|
-
], ["attachment"]) ||
|
|
2049
|
-
(context.attachment !== undefined &&
|
|
2050
|
-
!validAttachment(context.attachment)) ||
|
|
2051
|
-
!isNonEmptyString(context.worldId) ||
|
|
2052
|
-
!isNonEmptyString(context.parentHead) ||
|
|
2053
|
-
!isNonEmptyString(context.operationId) ||
|
|
2054
|
-
!isNonEmptyString(context.playPresetId) ||
|
|
2055
|
-
!isNonEmptyString(context.playPresetRevision) ||
|
|
2056
|
-
typeof context.playPresetScriptsEnabled !== "boolean" ||
|
|
2057
|
-
![
|
|
2058
|
-
"running",
|
|
2059
|
-
"core_failed",
|
|
2060
|
-
"core_materialization_pending",
|
|
2061
|
-
"core_committed",
|
|
2062
|
-
"completed",
|
|
2063
|
-
].includes(operation.status) ||
|
|
2064
|
-
![
|
|
2065
|
-
"not_started",
|
|
2066
|
-
"running",
|
|
2067
|
-
"completed",
|
|
2068
|
-
"failed",
|
|
2069
|
-
"cancelled",
|
|
2070
|
-
"budget_exceeded",
|
|
2071
|
-
"unknown",
|
|
2072
|
-
"recovery_required",
|
|
2073
|
-
"superseded",
|
|
2074
|
-
].includes(operation.extensionStatus) ||
|
|
2075
|
-
!Array.isArray(operation.completedRequests) ||
|
|
2076
|
-
!operation.completedRequests.every(isNonEmptyString) ||
|
|
2077
|
-
(operation.message !== undefined &&
|
|
2078
|
-
typeof operation.message !== "string") ||
|
|
2079
|
-
!validArtifactOperationState(operation) ||
|
|
2080
|
-
!["active", "superseded"].includes(operation.artifactProjectionStatus) ||
|
|
2081
|
-
!isRecord(operation.requestAttempts) ||
|
|
2082
|
-
!Object.entries(operation.requestAttempts).every(([request, attempt]) => request.trim() !== "" &&
|
|
2083
|
-
typeof attempt === "number" &&
|
|
2084
|
-
Number.isSafeInteger(attempt) &&
|
|
2085
|
-
attempt >= 1))
|
|
2086
|
-
throw new ArtifactStoreCorruptionError("Artifact operation state is corrupt");
|
|
2087
|
-
}
|
|
2088
|
-
function validArtifactOperationState(operation) {
|
|
2089
|
-
if (operation.status === "running")
|
|
2090
|
-
return (operation.extensionStatus === "not_started" &&
|
|
2091
|
-
operation.completedRequests.length === 0 &&
|
|
2092
|
-
operation.message === undefined &&
|
|
2093
|
-
operation.head === undefined);
|
|
2094
|
-
if (operation.status === "core_failed")
|
|
2095
|
-
return (operation.extensionStatus === "failed" &&
|
|
2096
|
-
operation.completedRequests.length === 0 &&
|
|
2097
|
-
operation.artifactProjectionStatus === "superseded" &&
|
|
2098
|
-
operation.head === undefined);
|
|
2099
|
-
if (operation.status === "core_materialization_pending")
|
|
2100
|
-
return (operation.extensionStatus === "recovery_required" &&
|
|
2101
|
-
operation.completedRequests.length === 0 &&
|
|
2102
|
-
operation.artifactProjectionStatus === "active" &&
|
|
2103
|
-
isNonEmptyString(operation.head) &&
|
|
2104
|
-
isNonEmptyString(operation.message));
|
|
2105
|
-
if (operation.status === "completed")
|
|
2106
|
-
return (operation.extensionStatus === "completed" &&
|
|
2107
|
-
isNonEmptyString(operation.head) &&
|
|
2108
|
-
operation.message === undefined);
|
|
2109
|
-
if (!isNonEmptyString(operation.head))
|
|
2110
|
-
return false;
|
|
2111
|
-
if (operation.extensionStatus === "completed")
|
|
2112
|
-
return false;
|
|
2113
|
-
if (operation.extensionStatus === "not_started" ||
|
|
2114
|
-
operation.extensionStatus === "running")
|
|
2115
|
-
return (operation.completedRequests.length === 0 &&
|
|
2116
|
-
operation.message === undefined);
|
|
2117
|
-
return (isNonEmptyString(operation.message) &&
|
|
2118
|
-
(operation.extensionStatus !== "superseded" ||
|
|
2119
|
-
operation.artifactProjectionStatus === "superseded"));
|
|
2120
|
-
}
|
|
2121
|
-
function assertEvent(event) {
|
|
2122
|
-
if (!isRecord(event) ||
|
|
2123
|
-
event.schemaVersion !== 1 ||
|
|
2124
|
-
!Number.isSafeInteger(event.sequence) ||
|
|
2125
|
-
event.sequence < 1 ||
|
|
2126
|
-
!isNonEmptyString(event.operationId))
|
|
2127
|
-
throw new ArtifactStoreCorruptionError("Artifact projection event is corrupt");
|
|
2128
|
-
const requestPair = event.requestId === undefined && event.requestAttempt === undefined
|
|
2129
|
-
? true
|
|
2130
|
-
: isNonEmptyString(event.requestId) &&
|
|
2131
|
-
Number.isSafeInteger(event.requestAttempt) &&
|
|
2132
|
-
Number(event.requestAttempt) >= 1;
|
|
2133
|
-
if (event.kind === "activate") {
|
|
2134
|
-
if (!hasExactKeys(event, ["schemaVersion", "kind", "sequence", "operationId", "recordId"], ["requestId", "requestAttempt"]) ||
|
|
2135
|
-
!isNonEmptyString(event.recordId) ||
|
|
2136
|
-
!requestPair)
|
|
2137
|
-
throw new ArtifactStoreCorruptionError("Artifact projection event is corrupt");
|
|
2138
|
-
return;
|
|
2139
|
-
}
|
|
2140
|
-
if (event.kind === "bind_head") {
|
|
2141
|
-
if (!hasExactKeys(event, [
|
|
2142
|
-
"schemaVersion",
|
|
2143
|
-
"kind",
|
|
2144
|
-
"sequence",
|
|
2145
|
-
"operationId",
|
|
2146
|
-
"recordId",
|
|
2147
|
-
"head",
|
|
2148
|
-
]) ||
|
|
2149
|
-
!isNonEmptyString(event.recordId) ||
|
|
2150
|
-
!isNonEmptyString(event.head))
|
|
2151
|
-
throw new ArtifactStoreCorruptionError("Artifact projection event is corrupt");
|
|
2152
|
-
return;
|
|
2153
|
-
}
|
|
2154
|
-
if (event.kind === "supersede") {
|
|
2155
|
-
if (!hasExactKeys(event, [
|
|
2156
|
-
"schemaVersion",
|
|
2157
|
-
"kind",
|
|
2158
|
-
"sequence",
|
|
2159
|
-
"operationId",
|
|
2160
|
-
"recordId",
|
|
2161
|
-
"reason",
|
|
2162
|
-
], ["requestId", "requestAttempt"]) ||
|
|
2163
|
-
!isNonEmptyString(event.recordId) ||
|
|
2164
|
-
!isNonEmptyString(event.reason) ||
|
|
2165
|
-
!requestPair)
|
|
2166
|
-
throw new ArtifactStoreCorruptionError("Artifact projection event is corrupt");
|
|
2167
|
-
return;
|
|
2168
|
-
}
|
|
2169
|
-
if (event.kind === "clear" || event.kind === "ignored_call") {
|
|
2170
|
-
if (!hasExactKeys(event, [
|
|
2171
|
-
"schemaVersion",
|
|
2172
|
-
"kind",
|
|
2173
|
-
"sequence",
|
|
2174
|
-
"operationId",
|
|
2175
|
-
"output",
|
|
2176
|
-
"channel",
|
|
2177
|
-
"reason",
|
|
2178
|
-
"toolCallId",
|
|
2179
|
-
"toolName",
|
|
2180
|
-
"callFingerprint",
|
|
2181
|
-
"requestId",
|
|
2182
|
-
"requestAttempt",
|
|
2183
|
-
], ["key"]) ||
|
|
2184
|
-
!isNonEmptyString(event.output) ||
|
|
2185
|
-
!isNonEmptyString(event.channel) ||
|
|
2186
|
-
!isNonEmptyString(event.reason) ||
|
|
2187
|
-
!isNonEmptyString(event.toolCallId) ||
|
|
2188
|
-
event.toolName !== "artifact_clear" ||
|
|
2189
|
-
!isSha256(event.callFingerprint) ||
|
|
2190
|
-
!isNonEmptyString(event.requestId) ||
|
|
2191
|
-
!Number.isSafeInteger(event.requestAttempt) ||
|
|
2192
|
-
Number(event.requestAttempt) < 1 ||
|
|
2193
|
-
(event.key !== undefined && !isNonEmptyString(event.key)))
|
|
2194
|
-
throw new ArtifactStoreCorruptionError("Artifact projection event is corrupt");
|
|
2195
|
-
return;
|
|
2196
|
-
}
|
|
2197
|
-
throw new ArtifactStoreCorruptionError("Artifact projection event is corrupt");
|
|
2198
|
-
}
|
|
2199
|
-
function isRecord(value) {
|
|
2200
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2201
|
-
}
|
|
2202
|
-
function hasExactKeys(value, required, optional = []) {
|
|
2203
|
-
const allowed = new Set([...required, ...optional]);
|
|
2204
|
-
return (required.every((key) => Object.hasOwn(value, key)) &&
|
|
2205
|
-
Object.keys(value).every((key) => allowed.has(key)));
|
|
2206
|
-
}
|
|
2207
|
-
function isNonEmptyString(value) {
|
|
2208
|
-
return typeof value === "string" && value.trim() !== "";
|
|
2209
|
-
}
|
|
2210
|
-
function isSha256(value) {
|
|
2211
|
-
return typeof value === "string" && /^sha256:[a-f0-9]{64}$/u.test(value);
|
|
2212
|
-
}
|
|
2213
|
-
function isMissing(error) {
|
|
2214
|
-
return isRecord(error) && error.code === "ENOENT";
|
|
2215
|
-
}
|
|
2216
|
-
function isAlreadyExists(error) {
|
|
2217
|
-
return isRecord(error) && error.code === "EEXIST";
|
|
2218
|
-
}
|
|
2219
|
-
function validAttachment(value) {
|
|
2220
|
-
return (isRecord(value) &&
|
|
2221
|
-
hasExactKeys(value, ["contextId", "eventId", "runId", "head"]) &&
|
|
2222
|
-
isNonEmptyString(value.head) &&
|
|
2223
|
-
isNonEmptyString(value.contextId) &&
|
|
2224
|
-
isNonEmptyString(value.runId) &&
|
|
2225
|
-
typeof value.eventId === "number" &&
|
|
2226
|
-
Number.isSafeInteger(value.eventId) &&
|
|
2227
|
-
value.eventId > 0);
|
|
2228
|
-
}
|