@playcraft/cli 0.0.59 → 0.1.1
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 +14 -2
- package/dist/agent/local-backend.js +1 -1
- package/dist/utils/agent-api-client.js +50 -7
- package/dist/utils/tool-operation-journal.js +82 -16
- package/dist/workspace-runtime/codex/app-server.js +135 -169
- package/dist/workspace-runtime/codex/jsonrpc-stdio.js +19 -2
- package/dist/workspace-runtime/codex/launcher-client.js +155 -0
- package/dist/workspace-runtime/codex/loopback.js +118 -37
- package/dist/workspace-runtime/codex/native-adapter.js +3 -0
- package/dist/workspace-runtime/persistence/journal.js +213 -200
- package/dist/workspace-runtime/persistence/sqlite-client.js +61 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +1025 -0
- package/dist/workspace-runtime/serve.js +35 -1
- package/dist/workspace-runtime/server/desktop-host.js +314 -0
- package/dist/workspace-runtime/server/dispatch.js +108 -48
- package/dist/workspace-runtime/server/http.js +260 -12
- package/dist/workspace-runtime/server/terminal-host.js +243 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +276 -0
- package/dist/workspace-runtime/workspaces/git-credential-helper.js +66 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +154 -0
- package/dist/workspace-runtime/workspaces/git.js +16 -3
- package/dist/workspace-runtime/workspaces/prepare.js +29 -172
- package/dist/workspace-runtime/workspaces/registry.js +12 -2
- package/package.json +7 -4
|
@@ -1,10 +1,27 @@
|
|
|
1
|
-
import { mkdir, readFile,
|
|
1
|
+
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { dirname, join } from 'node:path';
|
|
3
|
-
import { workspaceRegistrationSchema, checkpointRecordSchema, operationRecordSchema, pendingSourceEventSchema, sourceEventKey, sourceEventReceiptSchema, terminalRecordSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { workspaceRegistrationSchema, checkpointRecordSchema, operationRecordSchema, pendingSourceEventSchema, replayPinResultSchema, retainedRangeHasGap, sourceAckSchema, sourceEventKey, sourceEventReceiptSchema, terminalRecordSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { SqliteWorkerClient } from './sqlite-client.js';
|
|
6
|
+
export const RUNTIME_STATE_FILE = 'runtime-state.sqlite';
|
|
7
|
+
export const LEGACY_JOURNAL_FILE = 'journal.json';
|
|
8
|
+
export const LEGACY_JOURNAL_SENTINEL = 'PLAYCRAFT_RUNTIME_STATE_SQLITE\n';
|
|
4
9
|
export class RuntimeJournal {
|
|
5
10
|
filePath;
|
|
6
|
-
|
|
11
|
+
legacyPath;
|
|
12
|
+
client;
|
|
13
|
+
listeners = new Set();
|
|
14
|
+
maintenance;
|
|
15
|
+
engineInfo;
|
|
16
|
+
epochs = [];
|
|
17
|
+
readToken = '';
|
|
18
|
+
inboxPending = 0;
|
|
7
19
|
locks = new Map();
|
|
20
|
+
snapshot = emptySnapshot();
|
|
21
|
+
constructor(stateDir) {
|
|
22
|
+
this.filePath = join(stateDir, RUNTIME_STATE_FILE);
|
|
23
|
+
this.legacyPath = join(stateDir, LEGACY_JOURNAL_FILE);
|
|
24
|
+
}
|
|
8
25
|
async exclusive(key, action) {
|
|
9
26
|
const previous = this.locks.get(key) ?? Promise.resolve();
|
|
10
27
|
let release;
|
|
@@ -22,93 +39,52 @@ export class RuntimeJournal {
|
|
|
22
39
|
this.locks.delete(key);
|
|
23
40
|
}
|
|
24
41
|
}
|
|
25
|
-
snapshot = {
|
|
26
|
-
workspaces: {},
|
|
27
|
-
workspaceFileRevisions: {},
|
|
28
|
-
operations: {},
|
|
29
|
-
receipts: {},
|
|
30
|
-
events: {},
|
|
31
|
-
sessions: {},
|
|
32
|
-
terminals: {},
|
|
33
|
-
checkpoints: {},
|
|
34
|
-
fileRevisions: {},
|
|
35
|
-
approvals: {},
|
|
36
|
-
managedWrites: {},
|
|
37
|
-
executionGroup: { stopConfirmed: false, replaced: false },
|
|
38
|
-
lastSourceSequence: 0,
|
|
39
|
-
acceptedLeaseFence: 0,
|
|
40
|
-
destroyed: false,
|
|
41
|
-
};
|
|
42
|
-
constructor(stateDir) {
|
|
43
|
-
this.filePath = join(stateDir, 'journal.json');
|
|
44
|
-
}
|
|
45
42
|
async load() {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
consistentCheckpointId: typeof parsed.consistentCheckpointId === 'string' ? parsed.consistentCheckpointId : undefined,
|
|
82
|
-
approvals: Object.fromEntries(Object.entries(parsed.approvals ?? {})
|
|
83
|
-
.filter(([, value]) => isStoredApproval(value))
|
|
84
|
-
.map(([key, value]) => [key, value])),
|
|
85
|
-
executionGroup: {
|
|
86
|
-
pid: integerOrUndefined(parsed.executionGroup?.pid),
|
|
87
|
-
startedAt: typeof parsed.executionGroup?.startedAt === 'string'
|
|
88
|
-
? parsed.executionGroup.startedAt
|
|
89
|
-
: undefined,
|
|
90
|
-
previousPid: integerOrUndefined(parsed.executionGroup?.previousPid),
|
|
91
|
-
stopConfirmed: parsed.executionGroup?.stopConfirmed === true,
|
|
92
|
-
replaced: parsed.executionGroup?.replaced === true,
|
|
93
|
-
},
|
|
94
|
-
runtimeEpoch: integerOrUndefined(parsed.runtimeEpoch),
|
|
95
|
-
lastSourceSequence: parsed.lastSourceSequence ?? 0,
|
|
96
|
-
acceptedLeaseFence: parsed.acceptedLeaseFence ?? highestFence(parsed.operations ?? {}),
|
|
97
|
-
destroyed: parsed.destroyed === true,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
catch (error) {
|
|
101
|
-
if (error.code === 'ENOENT')
|
|
102
|
-
return;
|
|
103
|
-
throw error;
|
|
104
|
-
}
|
|
43
|
+
await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
44
|
+
const legacy = await readLegacy(this.legacyPath, this.filePath);
|
|
45
|
+
const started = await SqliteWorkerClient.open(this.filePath, legacy?.value ?? null);
|
|
46
|
+
this.client = started.client;
|
|
47
|
+
this.apply(started.opened);
|
|
48
|
+
if (legacy?.readable)
|
|
49
|
+
await writeFile(this.legacyPath, LEGACY_JOURNAL_SENTINEL, { mode: 0o600 });
|
|
50
|
+
this.maintenance = setInterval(() => {
|
|
51
|
+
void this.client?.request('maintain').catch(() => undefined);
|
|
52
|
+
}, 30_000);
|
|
53
|
+
this.maintenance.unref?.();
|
|
54
|
+
}
|
|
55
|
+
async close() {
|
|
56
|
+
if (this.maintenance)
|
|
57
|
+
clearInterval(this.maintenance);
|
|
58
|
+
await this.client?.close();
|
|
59
|
+
this.client = undefined;
|
|
60
|
+
}
|
|
61
|
+
engine() {
|
|
62
|
+
if (!this.engineInfo)
|
|
63
|
+
throw new Error('sqlite_engine_unavailable');
|
|
64
|
+
return this.engineInfo;
|
|
65
|
+
}
|
|
66
|
+
sourceReadToken() {
|
|
67
|
+
return this.readToken;
|
|
68
|
+
}
|
|
69
|
+
pendingInbox() {
|
|
70
|
+
return this.inboxPending;
|
|
71
|
+
}
|
|
72
|
+
epochsSnapshot() {
|
|
73
|
+
return this.epochs.map((epoch) => ({ ...epoch }));
|
|
74
|
+
}
|
|
75
|
+
onSourceEvent(listener) {
|
|
76
|
+
this.listeners.add(listener);
|
|
77
|
+
return () => this.listeners.delete(listener);
|
|
105
78
|
}
|
|
106
79
|
getWorkspace(workspaceId) {
|
|
107
80
|
return this.snapshot.workspaces[workspaceId];
|
|
108
81
|
}
|
|
109
82
|
async putWorkspace(binding) {
|
|
110
|
-
|
|
111
|
-
await this.
|
|
83
|
+
const parsed = workspaceRegistrationSchema.parse(binding);
|
|
84
|
+
await this.run('upsertRecord', { bucket: 'workspaces', key: parsed.workspaceId, body: parsed });
|
|
85
|
+
}
|
|
86
|
+
async forgetWorkspaces() {
|
|
87
|
+
await this.run('forgetWorkspaces', {});
|
|
112
88
|
}
|
|
113
89
|
findSessionCreation(sessionId) {
|
|
114
90
|
return Object.values(this.snapshot.operations).find((item) => item.kind === 'session.create' && item.sessionId === sessionId);
|
|
@@ -147,12 +123,10 @@ export class RuntimeJournal {
|
|
|
147
123
|
return this.snapshot.consistentCheckpointId;
|
|
148
124
|
}
|
|
149
125
|
async setFileCheckpointId(checkpointId) {
|
|
150
|
-
this.
|
|
151
|
-
await this.flush();
|
|
126
|
+
await this.run('setCheckpointId', { field: 'file', id: checkpointId });
|
|
152
127
|
}
|
|
153
128
|
async setConsistentCheckpointId(checkpointId) {
|
|
154
|
-
this.
|
|
155
|
-
await this.flush();
|
|
129
|
+
await this.run('setCheckpointId', { field: 'consistent', id: checkpointId });
|
|
156
130
|
}
|
|
157
131
|
listManagedWrites(workspaceId) {
|
|
158
132
|
return Object.values(this.snapshot.managedWrites).filter((item) => !workspaceId || item.workspaceId === workspaceId);
|
|
@@ -161,8 +135,7 @@ export class RuntimeJournal {
|
|
|
161
135
|
return this.snapshot.managedWrites[writeId];
|
|
162
136
|
}
|
|
163
137
|
async putManagedWrite(record) {
|
|
164
|
-
this.
|
|
165
|
-
await this.flush();
|
|
138
|
+
await this.run('upsertRecord', { bucket: 'managed_writes', key: record.writeId, body: record });
|
|
166
139
|
}
|
|
167
140
|
fileRevisions(workspaceId) {
|
|
168
141
|
return { ...(workspaceId ? this.snapshot.workspaceFileRevisions[workspaceId] ?? {} : this.snapshot.fileRevisions) };
|
|
@@ -186,165 +159,205 @@ export class RuntimeJournal {
|
|
|
186
159
|
return { ...this.snapshot.executionGroup };
|
|
187
160
|
}
|
|
188
161
|
getApproval(sessionId, approvalId) {
|
|
189
|
-
return this.snapshot.approvals[
|
|
162
|
+
return this.snapshot.approvals[`${sessionId}:${approvalId}`];
|
|
190
163
|
}
|
|
191
164
|
listApprovals() {
|
|
192
165
|
return Object.values(this.snapshot.approvals);
|
|
193
166
|
}
|
|
194
167
|
async putApproval(approval) {
|
|
195
|
-
this.
|
|
196
|
-
await this.flush();
|
|
168
|
+
await this.run('upsertRecord', { bucket: 'approvals', key: `${approval.sessionId}:${approval.approvalId}`, body: approval });
|
|
197
169
|
}
|
|
198
170
|
async noteExecutionGroup(group) {
|
|
199
|
-
this.
|
|
200
|
-
...this.snapshot.executionGroup,
|
|
201
|
-
pid: group.pid,
|
|
202
|
-
startedAt: group.startedAt,
|
|
203
|
-
};
|
|
204
|
-
await this.flush();
|
|
171
|
+
await this.run('noteExecutionGroup', { group });
|
|
205
172
|
}
|
|
206
173
|
async writeExecutionEpoch(input) {
|
|
207
|
-
this.
|
|
208
|
-
this.snapshot.executionGroup = {
|
|
209
|
-
pid: input.pid,
|
|
210
|
-
startedAt: input.startedAt,
|
|
211
|
-
previousPid: input.previousPid,
|
|
212
|
-
stopConfirmed: input.stopConfirmed,
|
|
213
|
-
replaced: true,
|
|
214
|
-
};
|
|
215
|
-
for (const [key, approval] of Object.entries(this.snapshot.approvals)) {
|
|
216
|
-
if (approval.status === 'pending') {
|
|
217
|
-
this.snapshot.approvals[key] = { ...approval, status: 'expired' };
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
await this.flush();
|
|
174
|
+
await this.run('writeExecutionEpoch', input);
|
|
221
175
|
}
|
|
222
176
|
async setExecutionStopConfirmed(stopConfirmed) {
|
|
223
|
-
this.
|
|
224
|
-
|
|
177
|
+
await this.run('setStopConfirmed', { stopConfirmed });
|
|
178
|
+
}
|
|
179
|
+
async bindRuntime(input) {
|
|
180
|
+
await this.run('bindRuntime', input);
|
|
225
181
|
}
|
|
226
182
|
listEvents(afterSequence = 0) {
|
|
227
183
|
return Object.values(this.snapshot.events)
|
|
228
184
|
.filter((event) => event.source.sourceSequence > afterSequence)
|
|
185
|
+
.sort((left, right) => left.source.sourceSequence - right.source.sourceSequence || left.source.runtimeEpoch - right.source.runtimeEpoch);
|
|
186
|
+
}
|
|
187
|
+
readSourcePage(input) {
|
|
188
|
+
const epoch = this.epochs.find((item) => item.runtime_id === input.runtimeId && item.generation === input.generation && item.runtime_epoch === input.runtimeEpoch);
|
|
189
|
+
const retainedFrom = epoch?.retained_from ?? 1;
|
|
190
|
+
const highWater = epoch?.local_durable_through ?? 0;
|
|
191
|
+
const coveredThrough = input.after;
|
|
192
|
+
if (retainedRangeHasGap(retainedFrom, coveredThrough)) {
|
|
193
|
+
return { ok: false, code: 'source_gap', retainedFrom, highWater, coveredThrough, ...identity(input) };
|
|
194
|
+
}
|
|
195
|
+
const matched = Object.values(this.snapshot.events)
|
|
196
|
+
.filter((event) => event.source.runtimeId === input.runtimeId &&
|
|
197
|
+
event.source.generation === input.generation &&
|
|
198
|
+
event.source.runtimeEpoch === input.runtimeEpoch &&
|
|
199
|
+
event.source.sourceSequence > input.after)
|
|
229
200
|
.sort((left, right) => left.source.sourceSequence - right.source.sourceSequence);
|
|
201
|
+
const first = matched[0];
|
|
202
|
+
if (first && first.source.sourceSequence > input.after + 1) {
|
|
203
|
+
return { ok: false, code: 'source_gap', retainedFrom, highWater, coveredThrough, ...identity(input) };
|
|
204
|
+
}
|
|
205
|
+
const events = matched.slice(0, input.limit ?? 100);
|
|
206
|
+
const nextAfter = events.at(-1)?.source.sourceSequence ?? input.after;
|
|
207
|
+
return { ok: true, events, retainedFrom, highWater, nextAfter, ...identity(input) };
|
|
208
|
+
}
|
|
209
|
+
async allocateSourceEvent(input) {
|
|
210
|
+
const result = await this.run('allocateEvent', input);
|
|
211
|
+
const event = result.committedEvents?.[0];
|
|
212
|
+
if (!event)
|
|
213
|
+
throw new Error('source_event_missing');
|
|
214
|
+
return event;
|
|
215
|
+
}
|
|
216
|
+
async commitSourceIdentity(event) {
|
|
217
|
+
await this.run('commitIdentity', { event: pendingSourceEventSchema.parse(event) });
|
|
218
|
+
}
|
|
219
|
+
async ingestNative(input) {
|
|
220
|
+
const result = await this.run('ingest', input);
|
|
221
|
+
return result.committedEvents ?? [];
|
|
222
|
+
}
|
|
223
|
+
async bindTurn(input) {
|
|
224
|
+
const result = await this.run('bindTurn', input);
|
|
225
|
+
return result.committedEvents ?? [];
|
|
226
|
+
}
|
|
227
|
+
async acknowledgeSource(ack) {
|
|
228
|
+
await this.run('ack', sourceAckSchema.parse(ack));
|
|
229
|
+
}
|
|
230
|
+
async createReplayPin(input) {
|
|
231
|
+
const result = await this.run('createPin', { identity: input, ttlMs: input.ttlMs });
|
|
232
|
+
return replayPinResultSchema.parse(result.pin);
|
|
233
|
+
}
|
|
234
|
+
async deleteReplayPin(pinId) {
|
|
235
|
+
await this.run('deletePin', { pinId });
|
|
236
|
+
}
|
|
237
|
+
async reclaimSourceEvents(minRetainMs = 0) {
|
|
238
|
+
await this.run('reclaim', { minRetainMs });
|
|
230
239
|
}
|
|
231
240
|
async putOperation(scopeKey, operation) {
|
|
232
|
-
|
|
233
|
-
await this.
|
|
234
|
-
return this.snapshot.operations[scopeKey];
|
|
241
|
+
const parsed = operationRecordSchema.parse(operation);
|
|
242
|
+
await this.run('putOperation', { scopeKey, operation: parsed });
|
|
243
|
+
return this.snapshot.operations[scopeKey] ?? parsed;
|
|
235
244
|
}
|
|
236
245
|
async putSession(binding) {
|
|
237
|
-
this.
|
|
238
|
-
await this.flush();
|
|
246
|
+
await this.run('upsertRecord', { bucket: 'sessions', key: binding.sessionId, body: binding });
|
|
239
247
|
}
|
|
240
248
|
async putTerminal(record) {
|
|
241
249
|
const parsed = terminalRecordSchema.parse(record);
|
|
242
|
-
this.
|
|
243
|
-
await this.flush();
|
|
250
|
+
await this.run('upsertRecord', { bucket: 'terminals', key: parsed.terminalId, body: parsed });
|
|
244
251
|
return parsed;
|
|
245
252
|
}
|
|
246
253
|
async putCheckpoint(checkpoint) {
|
|
247
254
|
const parsed = checkpointRecordSchema.parse(checkpoint);
|
|
248
|
-
this.
|
|
249
|
-
await this.flush();
|
|
255
|
+
await this.run('upsertRecord', { bucket: 'checkpoints', key: parsed.checkpointId, body: parsed });
|
|
250
256
|
return parsed;
|
|
251
257
|
}
|
|
252
258
|
async putFileRevision(path, record, workspaceId) {
|
|
253
259
|
if (workspaceId) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
else {
|
|
257
|
-
this.snapshot.fileRevisions[path] = record;
|
|
260
|
+
await this.run('upsertRecord', { bucket: `workspace_files:${workspaceId}`, key: path, body: record });
|
|
261
|
+
return;
|
|
258
262
|
}
|
|
259
|
-
await this.
|
|
263
|
+
await this.run('upsertRecord', { bucket: 'file_revisions', key: path, body: record });
|
|
260
264
|
}
|
|
261
265
|
async replaceFileRevisions(records, workspaceId) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
else
|
|
265
|
-
this.snapshot.fileRevisions = { ...records };
|
|
266
|
-
await this.flush();
|
|
266
|
+
const entries = Object.entries(records).map(([key, body]) => ({ key, body }));
|
|
267
|
+
await this.run('replaceBucket', { bucket: workspaceId ? `workspace_files:${workspaceId}` : 'file_revisions', entries });
|
|
267
268
|
}
|
|
268
269
|
async markDestroyed() {
|
|
269
|
-
this.
|
|
270
|
-
await this.flush();
|
|
270
|
+
await this.run('setDestroyed', {});
|
|
271
271
|
}
|
|
272
272
|
async replaceSessions(sessions) {
|
|
273
|
-
this.
|
|
274
|
-
|
|
273
|
+
await this.run('replaceBucket', {
|
|
274
|
+
bucket: 'sessions',
|
|
275
|
+
entries: sessions.map((session) => ({ key: session.sessionId, body: session })),
|
|
276
|
+
});
|
|
275
277
|
}
|
|
276
278
|
async setAcceptedLeaseFence(value) {
|
|
277
|
-
this.
|
|
278
|
-
await this.flush();
|
|
279
|
+
await this.run('setAcceptedFence', { value });
|
|
279
280
|
}
|
|
280
281
|
async putReceipt(receipt, sourceSequence) {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
await this.flush();
|
|
282
|
+
const parsed = sourceEventReceiptSchema.parse(receipt);
|
|
283
|
+
await this.run('upsertRecord', { bucket: 'receipts', key: sourceEventKey(parsed.source), body: parsed });
|
|
284
|
+
void sourceSequence;
|
|
285
285
|
}
|
|
286
286
|
async appendEvent(event) {
|
|
287
287
|
const parsed = pendingSourceEventSchema.parse(event);
|
|
288
|
-
this.
|
|
289
|
-
this.snapshot.
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
await rename(tempPath, this.filePath);
|
|
300
|
-
});
|
|
301
|
-
this.writes = write.catch(() => undefined);
|
|
302
|
-
await write;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
function sanitizeManagedWrites(value) {
|
|
306
|
-
if (!value || typeof value !== 'object')
|
|
307
|
-
return {};
|
|
308
|
-
const records = {};
|
|
309
|
-
for (const [key, item] of Object.entries(value)) {
|
|
310
|
-
if (!item || typeof item !== 'object')
|
|
311
|
-
continue;
|
|
312
|
-
const record = item;
|
|
313
|
-
if (record.writeId !== key || typeof record.workspaceId !== 'string' || typeof record.startedAt !== 'string') {
|
|
314
|
-
continue;
|
|
288
|
+
await this.commitSourceIdentity(parsed);
|
|
289
|
+
return this.snapshot.events[sourceEventKey(parsed.source)] ?? parsed;
|
|
290
|
+
}
|
|
291
|
+
async run(type, payload) {
|
|
292
|
+
if (!this.client)
|
|
293
|
+
throw new Error('sqlite_not_open');
|
|
294
|
+
const result = await this.client.request(type, payload);
|
|
295
|
+
this.apply(result);
|
|
296
|
+
for (const event of result.committedEvents ?? []) {
|
|
297
|
+
for (const listener of this.listeners)
|
|
298
|
+
listener(event);
|
|
315
299
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
apply(dump) {
|
|
303
|
+
const group = dump.snapshot.executionGroup;
|
|
304
|
+
this.snapshot = {
|
|
305
|
+
...emptySnapshot(),
|
|
306
|
+
...dump.snapshot,
|
|
307
|
+
executionGroup: {
|
|
308
|
+
stopConfirmed: group?.stopConfirmed === true,
|
|
309
|
+
replaced: group?.replaced === true,
|
|
310
|
+
pid: group?.pid,
|
|
311
|
+
startedAt: group?.startedAt,
|
|
312
|
+
previousPid: group?.previousPid,
|
|
313
|
+
},
|
|
325
314
|
};
|
|
315
|
+
this.epochs = dump.epochs ?? [];
|
|
316
|
+
this.readToken = dump.sourceReadToken ?? this.readToken;
|
|
317
|
+
this.inboxPending = dump.pendingInbox ?? 0;
|
|
318
|
+
if (dump.engine)
|
|
319
|
+
this.engineInfo = dump.engine;
|
|
326
320
|
}
|
|
327
|
-
return records;
|
|
328
321
|
}
|
|
329
|
-
function
|
|
330
|
-
|
|
322
|
+
async function readLegacy(legacyPath, sqlitePath) {
|
|
323
|
+
if (!existsSync(legacyPath))
|
|
324
|
+
return null;
|
|
325
|
+
const text = await readFile(legacyPath, 'utf8');
|
|
326
|
+
if (text.startsWith(LEGACY_JOURNAL_SENTINEL.trim())) {
|
|
327
|
+
if (!existsSync(sqlitePath))
|
|
328
|
+
throw new Error('runtime_state_missing');
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
let value;
|
|
332
|
+
try {
|
|
333
|
+
value = JSON.parse(text);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
throw new Error('legacy_journal_unreadable');
|
|
337
|
+
}
|
|
338
|
+
if (!existsSync(`${legacyPath}.bak`))
|
|
339
|
+
await copyFile(legacyPath, `${legacyPath}.bak`);
|
|
340
|
+
return { value, readable: true };
|
|
331
341
|
}
|
|
332
|
-
function
|
|
333
|
-
return
|
|
342
|
+
function identity(input) {
|
|
343
|
+
return { runtimeId: input.runtimeId, generation: input.generation, runtimeEpoch: input.runtimeEpoch };
|
|
334
344
|
}
|
|
335
|
-
function
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
|
|
345
|
+
function emptySnapshot() {
|
|
346
|
+
return {
|
|
347
|
+
workspaces: {},
|
|
348
|
+
workspaceFileRevisions: {},
|
|
349
|
+
operations: {},
|
|
350
|
+
receipts: {},
|
|
351
|
+
events: {},
|
|
352
|
+
sessions: {},
|
|
353
|
+
terminals: {},
|
|
354
|
+
checkpoints: {},
|
|
355
|
+
fileRevisions: {},
|
|
356
|
+
approvals: {},
|
|
357
|
+
managedWrites: {},
|
|
358
|
+
executionGroup: { stopConfirmed: false, replaced: false },
|
|
359
|
+
lastSourceSequence: 0,
|
|
360
|
+
acceptedLeaseFence: 0,
|
|
361
|
+
destroyed: false,
|
|
362
|
+
};
|
|
350
363
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { Worker } from 'node:worker_threads';
|
|
4
|
+
export class SqliteWorkerClient {
|
|
5
|
+
nextId = 1;
|
|
6
|
+
pending = new Map();
|
|
7
|
+
chain = Promise.resolve();
|
|
8
|
+
worker;
|
|
9
|
+
constructor(worker) {
|
|
10
|
+
this.worker = worker;
|
|
11
|
+
worker.on('message', (message) => {
|
|
12
|
+
const waiter = this.pending.get(message.id);
|
|
13
|
+
if (!waiter)
|
|
14
|
+
return;
|
|
15
|
+
this.pending.delete(message.id);
|
|
16
|
+
if (message.ok)
|
|
17
|
+
waiter.resolve(message.result);
|
|
18
|
+
else
|
|
19
|
+
waiter.reject(new Error(message.error || 'sqlite_worker_failed'));
|
|
20
|
+
});
|
|
21
|
+
worker.on('error', (error) => {
|
|
22
|
+
for (const waiter of this.pending.values())
|
|
23
|
+
waiter.reject(error);
|
|
24
|
+
this.pending.clear();
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
static async open(filePath, legacy) {
|
|
28
|
+
const client = new SqliteWorkerClient(new Worker(workerUrl(), { execArgv: [], env: { ...process.env, NODE_OPTIONS: '' } }));
|
|
29
|
+
try {
|
|
30
|
+
const opened = await client.request('open', { filePath, legacy });
|
|
31
|
+
return { client, opened };
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
await client.close().catch(() => undefined);
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
request(type, payload) {
|
|
39
|
+
const run = this.chain.then(() => this.send(type, payload));
|
|
40
|
+
this.chain = run.then(() => undefined, () => undefined);
|
|
41
|
+
return run;
|
|
42
|
+
}
|
|
43
|
+
async close() {
|
|
44
|
+
await this.request('close', {}).catch(() => undefined);
|
|
45
|
+
await this.worker.terminate();
|
|
46
|
+
}
|
|
47
|
+
send(type, payload) {
|
|
48
|
+
const id = this.nextId;
|
|
49
|
+
this.nextId += 1;
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
this.pending.set(id, { resolve: (value) => resolve(value), reject });
|
|
52
|
+
this.worker.postMessage({ id, type, payload });
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function workerUrl() {
|
|
57
|
+
const compiled = new URL('./sqlite-worker.js', import.meta.url);
|
|
58
|
+
if (existsSync(fileURLToPath(compiled)))
|
|
59
|
+
return compiled;
|
|
60
|
+
return new URL('./sqlite-worker.ts', import.meta.url);
|
|
61
|
+
}
|