@atolis-hq/wake 0.3.87 → 0.3.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/bootstrap/composition-root.js +1 -1
- package/dist/src/bootstrap/version.js +1 -1
- package/dist/src/execution/contracts/config.js +12 -0
- package/dist/src/execution/infrastructure/process-execution.js +27 -11
- package/dist/src/execution/infrastructure/workspace/fake-workspace.js +13 -1
- package/dist/src/execution/infrastructure/workspace/git-workspace.js +6 -1
- package/dist/src/execution/infrastructure/workspace/prepare-workspace.js +16 -0
- package/dist/src/persistence/filesystem/file-event-journal.js +224 -10
- package/package.json +1 -1
|
@@ -55,7 +55,7 @@ export async function createCompositionRoot(wakeRoot, options = {}) {
|
|
|
55
55
|
throw new Error('Workspace resource ' + id + ' does not identify a GitHub repository');
|
|
56
56
|
return 'https://github.com/' + match[1] + '.git';
|
|
57
57
|
},
|
|
58
|
-
});
|
|
58
|
+
}, undefined, undefined, config.execution.workspaceHooks?.prepare);
|
|
59
59
|
const transcriptStore = config.transcripts.enabled
|
|
60
60
|
? (options.transcriptStore ?? new TranscriptStore(paths.transcriptsRoot))
|
|
61
61
|
: undefined;
|
|
@@ -31,5 +31,17 @@ export const executionConfigSchema = z
|
|
|
31
31
|
leaseDurationMs: z.number().int().positive().optional(),
|
|
32
32
|
leaseRenewalIntervalMs: z.number().int().positive().optional(),
|
|
33
33
|
maxAmbiguityReconciliationAttempts: z.number().int().positive().optional(),
|
|
34
|
+
workspaceHooks: z
|
|
35
|
+
.object({
|
|
36
|
+
prepare: z
|
|
37
|
+
.object({
|
|
38
|
+
command: z.string().trim().min(1),
|
|
39
|
+
timeoutMs: z.number().int().positive().default(300_000),
|
|
40
|
+
})
|
|
41
|
+
.strict()
|
|
42
|
+
.optional(),
|
|
43
|
+
})
|
|
44
|
+
.strict()
|
|
45
|
+
.optional(),
|
|
34
46
|
})
|
|
35
47
|
.strict();
|
|
@@ -2,24 +2,26 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
// Agent CLIs can emit arbitrarily large machine-readable transcripts. Capture
|
|
3
3
|
// raw bytes ourselves so overflow never enters a third-party string buffer.
|
|
4
4
|
const maximumCapturedProcessOutputBytes = 1024 * 1024;
|
|
5
|
-
export function runProcess(command, args, cwd, signal, timeoutMs) {
|
|
5
|
+
export function runProcess(command, args, cwd, signal, timeoutMs, shell = false) {
|
|
6
6
|
const child = spawn(command, args, {
|
|
7
7
|
...(cwd === undefined ? {} : { cwd }),
|
|
8
|
-
shell
|
|
8
|
+
shell,
|
|
9
|
+
...(shell && process.platform !== 'win32' ? { detached: true } : {}),
|
|
9
10
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
10
11
|
});
|
|
11
|
-
const result = captureProcessOutput(child, signal, timeoutMs);
|
|
12
|
+
const result = captureProcessOutput(child, signal, timeoutMs, shell);
|
|
12
13
|
return {
|
|
13
14
|
result,
|
|
14
15
|
cancel: async () => {
|
|
15
|
-
terminate(child);
|
|
16
|
+
terminate(child, shell);
|
|
16
17
|
},
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
|
-
function captureProcessOutput(child, signal, timeoutMs) {
|
|
20
|
+
function captureProcessOutput(child, signal, timeoutMs, shell) {
|
|
20
21
|
return new Promise((resolve) => {
|
|
21
22
|
const stdout = [];
|
|
22
23
|
const stderr = [];
|
|
24
|
+
const combinedOutput = [];
|
|
23
25
|
let capturedBytes = 0;
|
|
24
26
|
let timedOut = false;
|
|
25
27
|
let overflowed = false;
|
|
@@ -28,20 +30,24 @@ function captureProcessOutput(child, signal, timeoutMs) {
|
|
|
28
30
|
overflowed = true;
|
|
29
31
|
child.stdout?.destroy();
|
|
30
32
|
child.stderr?.destroy();
|
|
31
|
-
terminate(child);
|
|
33
|
+
terminate(child, shell);
|
|
32
34
|
};
|
|
33
35
|
const capture = (destination) => (chunk) => {
|
|
34
36
|
if (overflowed)
|
|
35
37
|
return;
|
|
36
38
|
const remaining = maximumCapturedProcessOutputBytes - capturedBytes;
|
|
37
39
|
if (remaining <= 0 || chunk.length > remaining) {
|
|
38
|
-
if (remaining > 0)
|
|
39
|
-
|
|
40
|
+
if (remaining > 0) {
|
|
41
|
+
const captured = chunk.subarray(0, remaining);
|
|
42
|
+
destination.push(captured);
|
|
43
|
+
combinedOutput.push(captured);
|
|
44
|
+
}
|
|
40
45
|
capturedBytes = maximumCapturedProcessOutputBytes;
|
|
41
46
|
terminateForOverflow();
|
|
42
47
|
return;
|
|
43
48
|
}
|
|
44
49
|
destination.push(chunk);
|
|
50
|
+
combinedOutput.push(chunk);
|
|
45
51
|
capturedBytes += chunk.length;
|
|
46
52
|
};
|
|
47
53
|
child.stdout?.on('data', capture(stdout));
|
|
@@ -50,9 +56,9 @@ function captureProcessOutput(child, signal, timeoutMs) {
|
|
|
50
56
|
? undefined
|
|
51
57
|
: setTimeout(() => {
|
|
52
58
|
timedOut = true;
|
|
53
|
-
terminate(child);
|
|
59
|
+
terminate(child, shell);
|
|
54
60
|
}, timeoutMs);
|
|
55
|
-
const onAbort = () => terminate(child);
|
|
61
|
+
const onAbort = () => terminate(child, shell);
|
|
56
62
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
57
63
|
child.once('error', (caught) => {
|
|
58
64
|
error = caught;
|
|
@@ -64,6 +70,7 @@ function captureProcessOutput(child, signal, timeoutMs) {
|
|
|
64
70
|
resolve({
|
|
65
71
|
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
66
72
|
stderr: error?.message ?? Buffer.concat(stderr).toString('utf8'),
|
|
73
|
+
combinedOutput: Buffer.concat(combinedOutput),
|
|
67
74
|
exitCode: exitCode ?? undefined,
|
|
68
75
|
timedOut,
|
|
69
76
|
...(overflowed
|
|
@@ -76,7 +83,16 @@ function captureProcessOutput(child, signal, timeoutMs) {
|
|
|
76
83
|
});
|
|
77
84
|
});
|
|
78
85
|
}
|
|
79
|
-
function terminate(child) {
|
|
86
|
+
function terminate(child, shell = false) {
|
|
87
|
+
if (shell && process.platform !== 'win32' && child.pid !== undefined) {
|
|
88
|
+
try {
|
|
89
|
+
process.kill(-child.pid);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// The process may have already exited; fall through to the direct signal.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
80
96
|
if (!child.killed && child.exitCode === null)
|
|
81
97
|
child.kill();
|
|
82
98
|
}
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
export class FakeWorkspaceProvider {
|
|
2
2
|
path;
|
|
3
3
|
branch;
|
|
4
|
+
prepareHook;
|
|
5
|
+
prepareOutcome;
|
|
4
6
|
requests = [];
|
|
5
|
-
|
|
7
|
+
prepareInvocations = [];
|
|
8
|
+
constructor(path = '/fake/workspace', branch = 'wake/fake-work', prepareHook, prepareOutcome = { kind: 'success' }) {
|
|
6
9
|
this.path = path;
|
|
7
10
|
this.branch = branch;
|
|
11
|
+
this.prepareHook = prepareHook;
|
|
12
|
+
this.prepareOutcome = prepareOutcome;
|
|
8
13
|
}
|
|
9
14
|
async acquire(request) {
|
|
10
15
|
this.requests.push(request);
|
|
16
|
+
if (this.prepareHook !== undefined) {
|
|
17
|
+
this.prepareInvocations.push({ command: this.prepareHook.command, cwd: this.path });
|
|
18
|
+
if (this.prepareOutcome.kind === 'timed-out')
|
|
19
|
+
throw new Error(`Fake workspace prepare hook (${this.prepareHook.command}) timed out`);
|
|
20
|
+
if (this.prepareOutcome.kind === 'exit' && this.prepareOutcome.exitCode !== 0)
|
|
21
|
+
throw new Error(`Fake workspace prepare hook (${this.prepareHook.command}) exited with code ${this.prepareOutcome.exitCode}`);
|
|
22
|
+
}
|
|
11
23
|
return {
|
|
12
24
|
workspaceId: `workspace-${this.requests.length}`,
|
|
13
25
|
path: this.path,
|
|
@@ -3,11 +3,13 @@ import { access, mkdir, readdir, readFile, realpath, rm, writeFile } from 'node:
|
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { RunStatus, WorkspaceMode } from '../../contracts/vocabulary.js';
|
|
6
|
+
import { prepareWorkspace } from './prepare-workspace.js';
|
|
6
7
|
const exec = promisify(execFile);
|
|
7
8
|
export class GitWorkspaceProvider {
|
|
8
9
|
root;
|
|
9
10
|
resolver;
|
|
10
11
|
git;
|
|
12
|
+
prepareHook;
|
|
11
13
|
markerRoot;
|
|
12
14
|
recoveryFileSystem;
|
|
13
15
|
constructor(root, resolver, git = async (arguments_) => {
|
|
@@ -15,10 +17,11 @@ export class GitWorkspaceProvider {
|
|
|
15
17
|
}, recoveryFileSystem = {
|
|
16
18
|
remove: async (path) => rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
|
|
17
19
|
canonicalize: realpath,
|
|
18
|
-
}) {
|
|
20
|
+
}, prepareHook) {
|
|
19
21
|
this.root = root;
|
|
20
22
|
this.resolver = resolver;
|
|
21
23
|
this.git = git;
|
|
24
|
+
this.prepareHook = prepareHook;
|
|
22
25
|
this.markerRoot = join(this.root, '.wake-workspace-ownership');
|
|
23
26
|
this.recoveryFileSystem = recoveryFileSystem;
|
|
24
27
|
}
|
|
@@ -44,6 +47,8 @@ export class GitWorkspaceProvider {
|
|
|
44
47
|
const branch = request.mode === WorkspaceMode.Branch ? request.workItemId : undefined;
|
|
45
48
|
if (branch !== undefined)
|
|
46
49
|
await this.git(['-C', path, 'switch', ...(existingWorkspace ? [] : ['--create']), branch]);
|
|
50
|
+
if (this.prepareHook !== undefined)
|
|
51
|
+
await prepareWorkspace(path, this.prepareHook);
|
|
47
52
|
return {
|
|
48
53
|
workspaceId: name,
|
|
49
54
|
path,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { runProcess } from '../process-execution.js';
|
|
2
|
+
const prepareOutputTailBytes = 4096;
|
|
3
|
+
export async function prepareWorkspace(path, hook) {
|
|
4
|
+
const process = runProcess(hook.command, [], path, new AbortController().signal, hook.timeoutMs, true);
|
|
5
|
+
const result = await process.result;
|
|
6
|
+
if (result.exitCode === 0 && !result.timedOut && result.failureKind === undefined)
|
|
7
|
+
return;
|
|
8
|
+
throw new Error(prepareFailureMessage(hook.command, result));
|
|
9
|
+
}
|
|
10
|
+
function prepareFailureMessage(command, result) {
|
|
11
|
+
const reason = result.timedOut
|
|
12
|
+
? 'timed out'
|
|
13
|
+
: (result.failureMessage ?? `exited with code ${result.exitCode ?? 'unavailable'}`);
|
|
14
|
+
const output = result.combinedOutput.subarray(-prepareOutputTailBytes).toString('utf8');
|
|
15
|
+
return `Workspace prepare hook (${command}) ${reason}. Combined output tail (last ${prepareOutputTailBytes} bytes):\n${output}`;
|
|
16
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFile, mkdir, readdir, readFile, stat } from 'node:fs/promises';
|
|
1
|
+
import { appendFile, mkdir, open, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { isDeepStrictEqual } from 'node:util';
|
|
4
4
|
import { decodeEventEnvelope, InProcessJournalChangeSignal, WrongExpectedSequenceError, } from '../../kernel/index.js';
|
|
@@ -69,16 +69,60 @@ export class FileEventJournal {
|
|
|
69
69
|
const file = `${day}.jsonl`;
|
|
70
70
|
await appendFile(join(directory, file), newEnvelopes.map((event) => JSON.stringify(event)).join('\n') + '\n', 'utf8');
|
|
71
71
|
await this.extendCache(file, current, newEnvelopes);
|
|
72
|
+
// The manifest is derived data. The event append is authoritative, so
|
|
73
|
+
// an index-write failure must not turn a successfully recorded event
|
|
74
|
+
// into a failed append.
|
|
75
|
+
await this.persistManifest().catch(() => undefined);
|
|
72
76
|
this.changeSignalSource.notify();
|
|
73
77
|
}
|
|
74
78
|
return finalizedEnvelopes;
|
|
75
79
|
});
|
|
76
80
|
}
|
|
81
|
+
// Reads via the persisted per-segment manifest when the in-memory cache is
|
|
82
|
+
// cold (fresh process, or an on-disk change this instance didn't make),
|
|
83
|
+
// parsing only the segment files that can hold matching events instead of
|
|
84
|
+
// the entire history. A missing or stale manifest degrades to scan()'s
|
|
85
|
+
// full parse rather than to incorrect data; scan() then rebuilds it.
|
|
77
86
|
async readStream(stream) {
|
|
78
|
-
|
|
87
|
+
const streamKey = key(stream);
|
|
88
|
+
const entries = await this.readCurrentEntries();
|
|
89
|
+
if (this.cached !== undefined && sameEntries(this.cached.entries, entries))
|
|
90
|
+
return this.cached.events.filter((event) => key(event.stream) === streamKey);
|
|
91
|
+
const manifest = await this.loadManifest();
|
|
92
|
+
if (manifest !== undefined && isCompleteIndex(manifest, entries)) {
|
|
93
|
+
try {
|
|
94
|
+
return await this.parseIndexedEntries(manifest.segments.flatMap((segment) => segment.events
|
|
95
|
+
.filter((event) => event.stream === streamKey)
|
|
96
|
+
.map((event) => ({ ...event, file: segment.file }))));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// The segment stats guarded the common stale-index case. If an index
|
|
100
|
+
// is nevertheless internally inconsistent, the JSONL remains the
|
|
101
|
+
// authority and retains the original read semantics.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return (await this.scan(entries)).filter((event) => key(event.stream) === streamKey);
|
|
79
105
|
}
|
|
80
106
|
async readAll(after, limit = Number.POSITIVE_INFINITY) {
|
|
81
|
-
|
|
107
|
+
const entries = await this.readCurrentEntries();
|
|
108
|
+
if (this.cached !== undefined && sameEntries(this.cached.entries, entries))
|
|
109
|
+
return this.cached.events.filter((event) => event.globalPosition > after).slice(0, limit);
|
|
110
|
+
const manifest = await this.loadManifest();
|
|
111
|
+
if (manifest !== undefined && isCompleteIndex(manifest, entries)) {
|
|
112
|
+
try {
|
|
113
|
+
return await this.parseIndexedEntries(manifest.segments
|
|
114
|
+
.flatMap((segment) => segment.events
|
|
115
|
+
.filter((event) => event.globalPosition > after)
|
|
116
|
+
.map((event) => ({ ...event, file: segment.file })))
|
|
117
|
+
.slice(0, limit));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// See readStream(): never let a derived index change journal reads.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return (await this.scan(entries))
|
|
124
|
+
.filter((event) => event.globalPosition > after)
|
|
125
|
+
.slice(0, limit);
|
|
82
126
|
}
|
|
83
127
|
async readLatest(beforeGlobalPosition, limit = Number.POSITIVE_INFINITY) {
|
|
84
128
|
const events = await this.scan();
|
|
@@ -104,19 +148,76 @@ export class FileEventJournal {
|
|
|
104
148
|
const entries = priorEntries.some((entry) => entry.file === file)
|
|
105
149
|
? priorEntries.map((entry) => (entry.file === file ? updatedEntry : entry))
|
|
106
150
|
: [...priorEntries, updatedEntry];
|
|
107
|
-
this.cached
|
|
151
|
+
const priorSegments = this.cached?.segments ?? [];
|
|
152
|
+
const existingSegment = priorSegments.find((segment) => segment.file === file);
|
|
153
|
+
let offset = existingSegment?.size ?? 0;
|
|
154
|
+
const indexedEvents = newEnvelopes.map((event) => {
|
|
155
|
+
const length = Buffer.byteLength(`${JSON.stringify(event)}\n`);
|
|
156
|
+
const indexed = {
|
|
157
|
+
globalPosition: event.globalPosition,
|
|
158
|
+
stream: key(event.stream),
|
|
159
|
+
offset,
|
|
160
|
+
length,
|
|
161
|
+
};
|
|
162
|
+
offset += length;
|
|
163
|
+
return indexed;
|
|
164
|
+
});
|
|
165
|
+
const segments = existingSegment
|
|
166
|
+
? priorSegments.map((segment) => segment.file === file
|
|
167
|
+
? {
|
|
168
|
+
...updatedEntry,
|
|
169
|
+
startPosition: segment.startPosition,
|
|
170
|
+
count: segment.count + newEnvelopes.length,
|
|
171
|
+
events: [...segment.events, ...indexedEvents],
|
|
172
|
+
}
|
|
173
|
+
: segment)
|
|
174
|
+
: [
|
|
175
|
+
...priorSegments,
|
|
176
|
+
{
|
|
177
|
+
...updatedEntry,
|
|
178
|
+
startPosition: priorEvents.length + 1,
|
|
179
|
+
count: newEnvelopes.length,
|
|
180
|
+
events: indexedEvents,
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
this.cached = { entries, events: [...priorEvents, ...newEnvelopes], segments };
|
|
108
184
|
}
|
|
109
|
-
|
|
185
|
+
manifestPath() {
|
|
186
|
+
return join(this.root, 'events', 'index-manifest.json');
|
|
187
|
+
}
|
|
188
|
+
// Persisted alongside the segments, so any reader of the same on-disk
|
|
189
|
+
// journal — not just this instance — can skip straight to the segments
|
|
190
|
+
// that can hold what it's looking for on a cold cache. append() extends its
|
|
191
|
+
// contents from the concrete envelopes it has just written while locked.
|
|
192
|
+
async persistManifest() {
|
|
193
|
+
if (this.cached === undefined)
|
|
194
|
+
return;
|
|
195
|
+
const manifest = { segments: this.cached.segments };
|
|
196
|
+
await writeFile(this.manifestPath(), JSON.stringify(manifest), 'utf8');
|
|
197
|
+
}
|
|
198
|
+
async loadManifest() {
|
|
199
|
+
try {
|
|
200
|
+
const raw = await readFile(this.manifestPath(), 'utf8');
|
|
201
|
+
const parsed = JSON.parse(raw);
|
|
202
|
+
return isPersistedIndex(parsed) ? parsed : undefined;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Missing, corrupt, or foreign-shaped index: degrade to a full scan,
|
|
206
|
+
// which is rebuilt by the next append.
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
scan(precomputedEntries) {
|
|
110
211
|
if (this.inFlightScan !== undefined)
|
|
111
212
|
return this.inFlightScan;
|
|
112
|
-
const run = this.scanUncoalesced().finally(() => {
|
|
213
|
+
const run = this.scanUncoalesced(precomputedEntries).finally(() => {
|
|
113
214
|
if (this.inFlightScan === run)
|
|
114
215
|
this.inFlightScan = undefined;
|
|
115
216
|
});
|
|
116
217
|
this.inFlightScan = run;
|
|
117
218
|
return run;
|
|
118
219
|
}
|
|
119
|
-
async
|
|
220
|
+
async readCurrentEntries() {
|
|
120
221
|
const directory = join(this.root, 'events');
|
|
121
222
|
let files;
|
|
122
223
|
try {
|
|
@@ -129,17 +230,69 @@ export class FileEventJournal {
|
|
|
129
230
|
return [];
|
|
130
231
|
throw error;
|
|
131
232
|
}
|
|
132
|
-
|
|
233
|
+
return Promise.all(files.map(async (file) => {
|
|
133
234
|
const info = await stat(join(directory, file));
|
|
134
235
|
return { file, size: info.size, mtimeMs: info.mtimeMs };
|
|
135
236
|
}));
|
|
237
|
+
}
|
|
238
|
+
// Reads exactly the indexed JSONL records. Byte offsets make tail reads and
|
|
239
|
+
// stream reads proportional to the matching events, not segment size.
|
|
240
|
+
async parseIndexedEntries(entries) {
|
|
241
|
+
const directory = join(this.root, 'events');
|
|
242
|
+
const events = [];
|
|
243
|
+
const grouped = new Map();
|
|
244
|
+
for (const entry of entries) {
|
|
245
|
+
const group = grouped.get(entry.file) ?? [];
|
|
246
|
+
group.push(entry);
|
|
247
|
+
grouped.set(entry.file, group);
|
|
248
|
+
}
|
|
249
|
+
for (const [file, indexedEvents] of grouped) {
|
|
250
|
+
const handle = await open(join(directory, file), 'r');
|
|
251
|
+
try {
|
|
252
|
+
for (const indexed of indexedEvents) {
|
|
253
|
+
const buffer = Buffer.alloc(indexed.length);
|
|
254
|
+
const { bytesRead } = await handle.read(buffer, 0, indexed.length, indexed.offset);
|
|
255
|
+
events.push(this.decodeIndexedRecord(file, indexed, buffer, bytesRead));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
await handle.close();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return events;
|
|
263
|
+
}
|
|
264
|
+
decodeIndexedRecord(file, indexed, buffer, bytesRead) {
|
|
265
|
+
if (bytesRead !== indexed.length || buffer.at(-1) !== 10)
|
|
266
|
+
throw new Error(`Incomplete indexed record in ${file}`);
|
|
267
|
+
let input;
|
|
268
|
+
try {
|
|
269
|
+
input = JSON.parse(buffer.toString('utf8'));
|
|
270
|
+
const event = decodeEventEnvelope(input);
|
|
271
|
+
validateEnvelope(event, indexed.globalPosition);
|
|
272
|
+
if (key(event.stream) !== indexed.stream)
|
|
273
|
+
throw new Error('Indexed stream mismatch');
|
|
274
|
+
return event;
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
const context = eventContext(input);
|
|
278
|
+
throw new Error(`Corrupt indexed event at ${file}:${indexed.globalPosition}${context}: ${error.message}`, { cause: error });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async scanUncoalesced(precomputedEntries) {
|
|
282
|
+
const directory = join(this.root, 'events');
|
|
283
|
+
const entries = precomputedEntries ?? (await this.readCurrentEntries());
|
|
136
284
|
if (this.cached !== undefined && sameEntries(this.cached.entries, entries))
|
|
137
285
|
return this.cached.events;
|
|
138
286
|
const events = [];
|
|
139
|
-
|
|
287
|
+
const segments = [];
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
const { file } = entry;
|
|
140
290
|
const raw = await readFile(join(directory, file), 'utf8');
|
|
141
291
|
if (raw.length > 0 && !raw.endsWith('\n'))
|
|
142
292
|
throw new Error(`Incomplete trailing line in ${file}`);
|
|
293
|
+
const startPosition = events.length + 1;
|
|
294
|
+
const indexedEvents = [];
|
|
295
|
+
let offset = 0;
|
|
143
296
|
for (const [index, line] of raw.split('\n').slice(0, -1).entries()) {
|
|
144
297
|
let input;
|
|
145
298
|
try {
|
|
@@ -147,17 +300,78 @@ export class FileEventJournal {
|
|
|
147
300
|
const event = decodeEventEnvelope(input);
|
|
148
301
|
validateEnvelope(event, events.length + 1);
|
|
149
302
|
events.push(event);
|
|
303
|
+
indexedEvents.push({
|
|
304
|
+
globalPosition: event.globalPosition,
|
|
305
|
+
stream: key(event.stream),
|
|
306
|
+
offset,
|
|
307
|
+
length: Buffer.byteLength(`${line}\n`),
|
|
308
|
+
});
|
|
309
|
+
offset += Buffer.byteLength(`${line}\n`);
|
|
150
310
|
}
|
|
151
311
|
catch (error) {
|
|
152
312
|
const context = eventContext(input);
|
|
153
313
|
throw new Error(`Corrupt event at ${file}:${index + 1}${context}: ${error.message}`, { cause: error });
|
|
154
314
|
}
|
|
155
315
|
}
|
|
316
|
+
segments.push({
|
|
317
|
+
...entry,
|
|
318
|
+
startPosition,
|
|
319
|
+
count: events.length - startPosition + 1,
|
|
320
|
+
events: indexedEvents,
|
|
321
|
+
});
|
|
156
322
|
}
|
|
157
|
-
this.cached = { entries, events };
|
|
323
|
+
this.cached = { entries, events, segments };
|
|
158
324
|
return events;
|
|
159
325
|
}
|
|
160
326
|
}
|
|
327
|
+
function isPersistedIndex(value) {
|
|
328
|
+
return (typeof value === 'object' &&
|
|
329
|
+
value !== null &&
|
|
330
|
+
Array.isArray(value.segments) &&
|
|
331
|
+
value.segments.every(isSegmentInfo));
|
|
332
|
+
}
|
|
333
|
+
function isSegmentInfo(value) {
|
|
334
|
+
if (typeof value !== 'object' || value === null)
|
|
335
|
+
return false;
|
|
336
|
+
const segment = value;
|
|
337
|
+
return (typeof segment.file === 'string' &&
|
|
338
|
+
typeof segment.size === 'number' &&
|
|
339
|
+
typeof segment.mtimeMs === 'number' &&
|
|
340
|
+
typeof segment.startPosition === 'number' &&
|
|
341
|
+
typeof segment.count === 'number' &&
|
|
342
|
+
Array.isArray(segment.events) &&
|
|
343
|
+
segment.events.every(isIndexedEvent));
|
|
344
|
+
}
|
|
345
|
+
function isIndexedEvent(value) {
|
|
346
|
+
if (typeof value !== 'object' || value === null)
|
|
347
|
+
return false;
|
|
348
|
+
const event = value;
|
|
349
|
+
return (typeof event.globalPosition === 'number' &&
|
|
350
|
+
typeof event.stream === 'string' &&
|
|
351
|
+
typeof event.offset === 'number' &&
|
|
352
|
+
typeof event.length === 'number');
|
|
353
|
+
}
|
|
354
|
+
function isCompleteIndex(index, entries) {
|
|
355
|
+
if (!sameEntries(index.segments, entries))
|
|
356
|
+
return false;
|
|
357
|
+
let position = 1;
|
|
358
|
+
return index.segments.every((segment) => {
|
|
359
|
+
let offset = 0;
|
|
360
|
+
const complete = segment.startPosition === position &&
|
|
361
|
+
segment.count === segment.events.length &&
|
|
362
|
+
segment.events.every((event) => {
|
|
363
|
+
const matches = event.globalPosition === position &&
|
|
364
|
+
event.offset === offset &&
|
|
365
|
+
Number.isSafeInteger(event.length) &&
|
|
366
|
+
event.length > 0;
|
|
367
|
+
position += 1;
|
|
368
|
+
offset += event.length;
|
|
369
|
+
return matches;
|
|
370
|
+
}) &&
|
|
371
|
+
offset === segment.size;
|
|
372
|
+
return complete;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
161
375
|
function sameEntries(a, b) {
|
|
162
376
|
return (a.length === b.length &&
|
|
163
377
|
a.every((entry, index) => {
|