@atolis-hq/wake 0.3.87 → 0.3.88

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.
@@ -108,4 +108,4 @@ export function resolveWakeVersion(options = {}) {
108
108
  return `g${headHash.slice(0, 7)}`;
109
109
  return '0.1.0-dev';
110
110
  }
111
- export const wakeVersion = "g5ed53ba";
111
+ export const wakeVersion = "gead96c0";
@@ -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
- return (await this.scan()).filter((event) => key(event.stream) === key(stream));
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
- return (await this.scan()).filter((event) => event.globalPosition > after).slice(0, limit);
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 = { entries, events: [...priorEvents, ...newEnvelopes] };
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
- scan() {
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 scanUncoalesced() {
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
- const entries = await Promise.all(files.map(async (file) => {
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
- for (const file of files) {
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.3.87",
3
+ "version": "0.3.88",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {