@scotthuang/agent-knock-knock 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/store.js CHANGED
@@ -1,9 +1,17 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import fs from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  const PRIVATE_DIRECTORY_MODE = 0o700;
6
7
  const PRIVATE_FILE_MODE = 0o600;
8
+ const STORE_SCHEMA = "agent-knock-knock/store";
9
+ const STORE_MANIFEST_FILE = "manifest.json";
10
+ const STORE_CONVERSATIONS_DIRECTORY = "conversations";
11
+ const STORE_RUNTIME_DIRECTORY = "runtime";
12
+ const STORE_WRITER_LOCK_FILE = ".akk-writer.lock";
13
+ const STORE_MANIFEST_TEMP_PREFIX = `.${STORE_MANIFEST_FILE}.`;
14
+ const STORE_MANIFEST_TEMP_SUFFIX = ".tmp";
7
15
  const STORE_LOCK_FILE = ".akk-store.lock";
8
16
  const STORE_LOCK_RECLAIM_SUFFIX = ".reclaim";
9
17
  const STORE_LOCK_TIMEOUT_MS = 10_000;
@@ -12,8 +20,21 @@ const STORE_LOCK_INVALID_STALE_MS = 30_000;
12
20
  const NO_FOLLOW_FLAG = typeof fs.constants.O_NOFOLLOW === "number"
13
21
  ? fs.constants.O_NOFOLLOW
14
22
  : 0;
23
+ export const STORE_FORMAT_VERSION = 1;
24
+ export const STORE_WRITER_PROTOCOL = 1;
25
+ const STORE_WRITER_LEASE_BRAND = Symbol("akk-store-writer-lease");
26
+ const activeStoreWriterLease = new AsyncLocalStorage();
27
+ export class StoreCompatibilityError extends Error {
28
+ code = "AKK_STORE_INCOMPATIBLE";
29
+ compatibility;
30
+ constructor(message, compatibility) {
31
+ super(message);
32
+ this.name = "StoreCompatibilityError";
33
+ this.compatibility = compatibility;
34
+ }
35
+ }
15
36
  export function defaultStoreDir(_workspace = process.cwd()) {
16
- return path.join(os.homedir(), ".agent-knock-knock", "conversations");
37
+ return path.join(os.homedir(), ".agent-knock-knock", "store");
17
38
  }
18
39
  export function defaultLogDir(workspace = process.cwd()) {
19
40
  return defaultStoreDir(workspace);
@@ -28,14 +49,166 @@ export function ensureDir(dir) {
28
49
  }
29
50
  fs.chmodSync(resolvedDir, PRIVATE_DIRECTORY_MODE);
30
51
  }
52
+ export function storeManifestPath(storeDir = defaultStoreDir()) {
53
+ return path.join(storeDir, STORE_MANIFEST_FILE);
54
+ }
55
+ export function storeConversationsDir(storeDir = defaultStoreDir()) {
56
+ return path.join(storeDir, STORE_CONVERSATIONS_DIRECTORY);
57
+ }
58
+ export function inspectStoreCompatibility(storeDir = defaultStoreDir()) {
59
+ const resolvedStoreDir = path.resolve(storeDir);
60
+ const manifestPath = storeManifestPath(resolvedStoreDir);
61
+ if (!fs.existsSync(resolvedStoreDir)) {
62
+ return {
63
+ status: "uninitialized",
64
+ store_dir: storeDir,
65
+ manifest_path: manifestPath,
66
+ readable: true,
67
+ writable: true
68
+ };
69
+ }
70
+ assertNotSymlink(resolvedStoreDir, "store directory");
71
+ const storeStat = fs.lstatSync(resolvedStoreDir);
72
+ if (!storeStat.isDirectory()) {
73
+ throw new Error(`store directory must be a real directory: ${storeDir}`);
74
+ }
75
+ if (!fs.existsSync(manifestPath)) {
76
+ if (storeHasConversationData(resolvedStoreDir)) {
77
+ return {
78
+ status: "legacy",
79
+ store_dir: storeDir,
80
+ manifest_path: manifestPath,
81
+ readable: false,
82
+ writable: false,
83
+ reason: "store contains conversation data but has no AKK manifest"
84
+ };
85
+ }
86
+ return {
87
+ status: "uninitialized",
88
+ store_dir: storeDir,
89
+ manifest_path: manifestPath,
90
+ readable: true,
91
+ writable: true
92
+ };
93
+ }
94
+ const manifest = readStoreManifest(manifestPath);
95
+ const readable = manifest.format_version === STORE_FORMAT_VERSION;
96
+ const writable = readable && manifest.writer_protocol === STORE_WRITER_PROTOCOL;
97
+ return {
98
+ status: readable && writable ? "compatible" : "incompatible",
99
+ store_dir: storeDir,
100
+ manifest_path: manifestPath,
101
+ readable,
102
+ writable,
103
+ format_version: manifest.format_version,
104
+ writer_protocol: manifest.writer_protocol,
105
+ ...(!readable
106
+ ? {
107
+ reason: `store format ${manifest.format_version} is not readable by format ${STORE_FORMAT_VERSION}`
108
+ }
109
+ : !writable
110
+ ? {
111
+ reason: `store writer protocol ${manifest.writer_protocol} is not writable by protocol ${STORE_WRITER_PROTOCOL}`
112
+ }
113
+ : {})
114
+ };
115
+ }
116
+ export function assertStoreReadable(storeDir = defaultStoreDir()) {
117
+ const compatibility = inspectStoreCompatibility(storeDir);
118
+ if (!compatibility.readable) {
119
+ throw new StoreCompatibilityError(`${compatibility.reason}; use an empty AKK store created by the installed package`, compatibility);
120
+ }
121
+ return compatibility;
122
+ }
123
+ /**
124
+ * Validate whether this binary may write the selected store without creating or
125
+ * repairing anything. An absent or empty store is writable because its first
126
+ * real mutation may initialize the manifest.
127
+ */
128
+ export function assertStoreWriterCompatible(storeDir = defaultStoreDir()) {
129
+ const compatibility = inspectStoreCompatibility(storeDir);
130
+ assertWritableCompatibility(compatibility);
131
+ return compatibility;
132
+ }
133
+ export function ensureStoreWritable(storeDir = defaultStoreDir()) {
134
+ return withStoreWriterLease(storeDir, (lease) => ({ ...lease.manifest }));
135
+ }
136
+ /**
137
+ * Hold the store's writer lock for one synchronous mutation boundary. Nested
138
+ * calls for the same store reuse the active lease, so saveState/appendEvent can
139
+ * safely enforce the guard without deadlocking a command-level lease.
140
+ */
141
+ export function withStoreWriterLease(storeDir, action) {
142
+ const resolvedStoreDir = path.resolve(storeDir);
143
+ const current = activeStoreWriterLease.getStore();
144
+ if (current && !current.released) {
145
+ assertSameStoreLease(current, resolvedStoreDir);
146
+ revalidateStoreWriterLease(current);
147
+ return action(current);
148
+ }
149
+ prepareStoreRootForWriterLock(resolvedStoreDir);
150
+ const lockPath = path.join(resolvedStoreDir, STORE_WRITER_LOCK_FILE);
151
+ const token = randomUUID();
152
+ acquireConversationLock(lockPath, token, Date.now() + STORE_LOCK_TIMEOUT_MS);
153
+ let lease;
154
+ try {
155
+ const manifest = ensureStoreWritableWhileLocked(resolvedStoreDir);
156
+ lease = {
157
+ storeDir: resolvedStoreDir,
158
+ manifest: Object.freeze({ ...manifest }),
159
+ [STORE_WRITER_LEASE_BRAND]: true,
160
+ released: false
161
+ };
162
+ return activeStoreWriterLease.run(lease, () => action(lease));
163
+ }
164
+ finally {
165
+ if (lease) {
166
+ lease.released = true;
167
+ }
168
+ releaseConversationLock(lockPath, token);
169
+ }
170
+ }
171
+ /** Hold the store writer lease until an asynchronous side effect and commit finish. */
172
+ export async function withStoreWriterLeaseAsync(storeDir, action) {
173
+ const resolvedStoreDir = path.resolve(storeDir);
174
+ const current = activeStoreWriterLease.getStore();
175
+ if (current && !current.released) {
176
+ assertSameStoreLease(current, resolvedStoreDir);
177
+ revalidateStoreWriterLease(current);
178
+ return action(current);
179
+ }
180
+ prepareStoreRootForWriterLock(resolvedStoreDir);
181
+ const lockPath = path.join(resolvedStoreDir, STORE_WRITER_LOCK_FILE);
182
+ const token = randomUUID();
183
+ acquireConversationLock(lockPath, token, Date.now() + STORE_LOCK_TIMEOUT_MS);
184
+ let lease;
185
+ try {
186
+ const manifest = ensureStoreWritableWhileLocked(resolvedStoreDir);
187
+ lease = {
188
+ storeDir: resolvedStoreDir,
189
+ manifest: Object.freeze({ ...manifest }),
190
+ [STORE_WRITER_LEASE_BRAND]: true,
191
+ released: false
192
+ };
193
+ return await activeStoreWriterLease.run(lease, () => action(lease));
194
+ }
195
+ finally {
196
+ if (lease) {
197
+ lease.released = true;
198
+ }
199
+ releaseConversationLock(lockPath, token);
200
+ }
201
+ }
31
202
  export function pathsForConversation(conversationId, storeDir = defaultStoreDir()) {
32
203
  const validated = validateConversationPath(conversationId, storeDir);
33
- const conversationDir = path.join(storeDir, conversationId);
204
+ const conversationsDir = storeConversationsDir(storeDir);
205
+ const conversationDir = path.join(conversationsDir, conversationId);
34
206
  assertNotSymlink(validated.resolvedStoreDir, "store directory");
207
+ assertNotSymlink(validated.resolvedConversationsDir, "conversations directory");
35
208
  assertNotSymlink(validated.resolvedConversationDir, "conversation directory");
36
209
  return {
37
210
  storeDir,
38
- logDir: storeDir,
211
+ logDir: conversationsDir,
39
212
  conversationDir,
40
213
  logPath: path.join(conversationDir, "events.ndjson"),
41
214
  statePath: path.join(conversationDir, "state.json")
@@ -43,14 +216,19 @@ export function pathsForConversation(conversationId, storeDir = defaultStoreDir(
43
216
  }
44
217
  export function pathsForConversationDir(conversationDir) {
45
218
  const resolvedConversationDir = path.resolve(conversationDir);
46
- const resolvedStoreDir = path.dirname(resolvedConversationDir);
47
- if (resolvedConversationDir === resolvedStoreDir) {
48
- throw new Error(`conversation directory must be contained by a store directory: ${conversationDir}`);
219
+ validateConversationId(path.basename(resolvedConversationDir));
220
+ const resolvedConversationsDir = path.dirname(resolvedConversationDir);
221
+ const resolvedStoreDir = path.dirname(resolvedConversationsDir);
222
+ if (path.basename(resolvedConversationsDir) !== STORE_CONVERSATIONS_DIRECTORY ||
223
+ resolvedConversationDir === resolvedConversationsDir ||
224
+ resolvedConversationsDir === resolvedStoreDir) {
225
+ throw new Error(`conversation directory must be contained by an AKK store conversations directory: ${conversationDir}`);
49
226
  }
50
227
  assertNotSymlink(resolvedStoreDir, "store directory");
228
+ assertNotSymlink(resolvedConversationsDir, "conversations directory");
51
229
  assertNotSymlink(resolvedConversationDir, "conversation directory");
52
230
  return {
53
- storeDir: path.dirname(conversationDir),
231
+ storeDir: path.dirname(path.dirname(conversationDir)),
54
232
  logDir: path.dirname(conversationDir),
55
233
  conversationDir,
56
234
  logPath: path.join(conversationDir, "events.ndjson"),
@@ -64,6 +242,12 @@ export function logPathForStatePath(statePath) {
64
242
  return statePath.replace(/\.state\.json$/, ".ndjson");
65
243
  }
66
244
  export function saveState(statePath, conversation) {
245
+ const paths = assertCanonicalConversationDataPath(statePath, "state.json");
246
+ withStoreWriterLease(paths.storeDir, () => {
247
+ saveStateWithWriterLease(statePath, conversation);
248
+ });
249
+ }
250
+ function saveStateWithWriterLease(statePath, conversation) {
67
251
  validateConversationId(conversation.conversation_id);
68
252
  secureConversationStorageMetadata(statePath, conversation);
69
253
  prepareDataDirectory(statePath);
@@ -105,7 +289,6 @@ export function loadState(statePath) {
105
289
  assertNotSymlink(path.dirname(statePath), "conversation directory");
106
290
  const fd = openRegularFileNoFollow(statePath, fs.constants.O_RDONLY, "state file");
107
291
  try {
108
- fs.fchmodSync(fd, PRIVATE_FILE_MODE);
109
292
  return JSON.parse(fs.readFileSync(fd, "utf8"));
110
293
  }
111
294
  finally {
@@ -116,29 +299,50 @@ export function statePathForConversationId(conversationId, storeDir = defaultSto
116
299
  return pathsForConversation(conversationId, storeDir).statePath;
117
300
  }
118
301
  export function loadConversationById(conversationId, storeDir = defaultStoreDir()) {
119
- return loadState(statePathForConversationId(conversationId, storeDir));
302
+ const resolvedStoreDir = path.resolve(storeDir);
303
+ assertStoreReadable(resolvedStoreDir);
304
+ const paths = pathsForConversation(conversationId, resolvedStoreDir);
305
+ return withCanonicalConversationStorage(loadState(paths.statePath), paths);
120
306
  }
121
307
  export function listConversations(storeDir = defaultStoreDir()) {
122
- if (!fs.existsSync(storeDir)) {
308
+ const resolvedStoreDir = path.resolve(storeDir);
309
+ if (!fs.existsSync(resolvedStoreDir)) {
310
+ return [];
311
+ }
312
+ assertStoreReadable(resolvedStoreDir);
313
+ assertNotSymlink(resolvedStoreDir, "store directory");
314
+ const conversationsDir = storeConversationsDir(resolvedStoreDir);
315
+ if (!fs.existsSync(conversationsDir)) {
123
316
  return [];
124
317
  }
125
- assertNotSymlink(path.resolve(storeDir), "store directory");
126
- return fs.readdirSync(storeDir, { withFileTypes: true })
318
+ assertNotSymlink(path.resolve(conversationsDir), "conversations directory");
319
+ return fs.readdirSync(conversationsDir, { withFileTypes: true })
127
320
  .filter((entry) => entry.isDirectory())
128
- .map((entry) => pathsForConversation(entry.name, storeDir).statePath)
129
- .filter((statePath) => fs.existsSync(statePath))
130
- .map((statePath) => {
131
- const conversation = loadState(statePath);
132
- return {
133
- ...conversation,
134
- state_path: conversation.state_path ?? statePath,
135
- event_log_path: conversation.event_log_path ?? logPathForStatePath(statePath),
136
- conversation_dir: conversation.conversation_dir ?? path.dirname(statePath)
137
- };
138
- })
321
+ .map((entry) => pathsForConversation(entry.name, resolvedStoreDir))
322
+ .filter((paths) => fs.existsSync(paths.statePath))
323
+ .map((paths) => withCanonicalConversationStorage(loadState(paths.statePath), paths))
139
324
  .sort((left, right) => String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? "")));
140
325
  }
326
+ function withCanonicalConversationStorage(conversation, paths) {
327
+ const canonical = pathsForConversation(conversation.conversation_id, paths.storeDir);
328
+ if (path.resolve(canonical.statePath) !== path.resolve(paths.statePath)) {
329
+ throw new Error(`conversation id does not match its store directory: ${paths.statePath}`);
330
+ }
331
+ return {
332
+ ...conversation,
333
+ store_dir: path.resolve(paths.storeDir),
334
+ conversation_dir: path.resolve(paths.conversationDir),
335
+ state_path: path.resolve(paths.statePath),
336
+ event_log_path: path.resolve(paths.logPath)
337
+ };
338
+ }
141
339
  export function appendEvent(logPath, event) {
340
+ const paths = assertCanonicalConversationDataPath(logPath, "events.ndjson");
341
+ withStoreWriterLease(paths.storeDir, () => {
342
+ appendEventWithWriterLease(logPath, event);
343
+ });
344
+ }
345
+ function appendEventWithWriterLease(logPath, event) {
142
346
  const serialized = `${JSON.stringify(event)}\n`;
143
347
  secureEventStorageMetadata(logPath, event);
144
348
  prepareDataDirectory(logPath);
@@ -214,19 +418,22 @@ function validateConversationId(conversationId) {
214
418
  function validateConversationPath(conversationId, storeDir) {
215
419
  validateConversationId(conversationId);
216
420
  const resolvedStoreDir = path.resolve(storeDir);
217
- const resolvedConversationDir = path.resolve(resolvedStoreDir, conversationId);
218
- if (path.dirname(resolvedConversationDir) !== resolvedStoreDir) {
421
+ const resolvedConversationsDir = path.resolve(resolvedStoreDir, STORE_CONVERSATIONS_DIRECTORY);
422
+ const resolvedConversationDir = path.resolve(resolvedConversationsDir, conversationId);
423
+ if (path.dirname(resolvedConversationDir) !== resolvedConversationsDir) {
219
424
  throw new Error(`conversation id escapes the store directory: ${conversationId}`);
220
425
  }
221
426
  return {
222
427
  resolvedStoreDir,
428
+ resolvedConversationsDir,
223
429
  resolvedConversationDir
224
430
  };
225
431
  }
226
432
  function prepareDataDirectory(dataPath) {
227
433
  const directory = path.dirname(dataPath);
228
434
  if (path.basename(dataPath) === "state.json" || path.basename(dataPath) === "events.ndjson") {
229
- assertNotSymlink(path.resolve(path.dirname(directory)), "store directory");
435
+ const paths = pathsForConversationDir(directory);
436
+ ensureStoreWritable(paths.storeDir);
230
437
  ensureDir(directory);
231
438
  return;
232
439
  }
@@ -240,40 +447,59 @@ function secureConversationStorageMetadata(statePath, conversation) {
240
447
  if (typeof conversation.store_dir !== "string" ||
241
448
  typeof conversation.conversation_dir !== "string" ||
242
449
  typeof conversation.state_path !== "string") {
243
- return;
450
+ throw new Error(`conversation storage metadata is required for state writes: ${statePath}`);
244
451
  }
245
452
  const paths = pathsForConversation(conversation.conversation_id, conversation.store_dir);
246
453
  if (path.resolve(paths.conversationDir) !== path.resolve(conversation.conversation_dir) ||
247
454
  path.resolve(paths.statePath) !== path.resolve(conversation.state_path) ||
248
455
  path.resolve(paths.statePath) !== path.resolve(statePath)) {
249
- return;
456
+ throw new Error(`conversation storage metadata does not match state path: ${statePath}`);
250
457
  }
458
+ ensureStoreWritable(paths.storeDir);
251
459
  ensureStoreDir(paths.storeDir, paths.conversationDir);
252
460
  ensureDir(paths.conversationDir);
253
461
  }
254
462
  function secureEventStorageMetadata(logPath, event) {
255
463
  if (typeof event.conversation_id !== "string") {
256
- return;
464
+ throw new Error(`conversation_id is required for event writes: ${logPath}`);
257
465
  }
258
466
  const conversationDir = path.dirname(logPath);
259
467
  validateConversationId(event.conversation_id);
260
468
  if (path.basename(conversationDir) !== event.conversation_id) {
261
- return;
469
+ throw new Error(`event conversation_id does not match its directory: ${logPath}`);
262
470
  }
263
- const storeDir = path.dirname(conversationDir);
471
+ const conversationsDir = path.dirname(conversationDir);
472
+ if (path.basename(conversationsDir) !== STORE_CONVERSATIONS_DIRECTORY) {
473
+ throw new Error(`event log is outside an AKK conversations directory: ${logPath}`);
474
+ }
475
+ const storeDir = path.dirname(conversationsDir);
264
476
  const paths = pathsForConversation(event.conversation_id, storeDir);
265
477
  if (path.resolve(paths.logPath) !== path.resolve(logPath)) {
266
- return;
478
+ throw new Error(`event storage metadata does not match log path: ${logPath}`);
267
479
  }
480
+ ensureStoreWritable(paths.storeDir);
268
481
  ensureStoreDir(paths.storeDir, paths.conversationDir);
269
482
  ensureDir(paths.conversationDir);
270
483
  }
484
+ function assertCanonicalConversationDataPath(dataPath, expectedBasename) {
485
+ if (path.basename(dataPath) !== expectedBasename) {
486
+ throw new Error(`AKK ${expectedBasename} writes require <store>/conversations/<id>/${expectedBasename}: ${dataPath}`);
487
+ }
488
+ const paths = pathsForConversationDir(path.dirname(dataPath));
489
+ const canonical = pathsForConversation(path.basename(paths.conversationDir), paths.storeDir);
490
+ const expectedPath = expectedBasename === "state.json"
491
+ ? canonical.statePath
492
+ : canonical.logPath;
493
+ if (path.resolve(expectedPath) !== path.resolve(dataPath)) {
494
+ throw new Error(`AKK conversation data path is not canonical: ${dataPath}`);
495
+ }
496
+ return canonical;
497
+ }
271
498
  function ensureStoreDir(storeDir, currentConversationDir) {
272
499
  const resolvedStoreDir = path.resolve(storeDir);
273
500
  assertNotSymlink(resolvedStoreDir, "store directory");
274
501
  if (!fs.existsSync(resolvedStoreDir)) {
275
- ensureDir(resolvedStoreDir);
276
- return;
502
+ prepareStoreRootForWriterLock(resolvedStoreDir);
277
503
  }
278
504
  const stat = fs.lstatSync(resolvedStoreDir);
279
505
  if (!stat.isDirectory()) {
@@ -284,22 +510,168 @@ function ensureStoreDir(storeDir, currentConversationDir) {
284
510
  }
285
511
  const entries = fs.readdirSync(resolvedStoreDir, { withFileTypes: true });
286
512
  const resolvedCurrentConversationDir = path.resolve(currentConversationDir);
287
- const looksDedicated = resolvedStoreDir === path.resolve(defaultStoreDir()) ||
288
- entries.length === 0 ||
289
- entries.every((entry) => {
290
- if (!entry.isDirectory()) {
513
+ const resolvedConversationsDir = path.resolve(storeConversationsDir(resolvedStoreDir));
514
+ const looksDedicated = entries.length === 0 || entries.every((entry) => {
515
+ const entryPath = path.join(resolvedStoreDir, entry.name);
516
+ if (entry.name === STORE_MANIFEST_FILE && entry.isFile()) {
517
+ return true;
518
+ }
519
+ if (entry.name === STORE_RUNTIME_DIRECTORY && entry.isDirectory()) {
520
+ return true;
521
+ }
522
+ if (entryPath !== resolvedConversationsDir || !entry.isDirectory()) {
523
+ return false;
524
+ }
525
+ return fs.readdirSync(resolvedConversationsDir, { withFileTypes: true })
526
+ .every((conversationEntry) => {
527
+ if (!conversationEntry.isDirectory()) {
291
528
  return false;
292
529
  }
293
- const entryPath = path.join(resolvedStoreDir, entry.name);
294
- return entryPath === resolvedCurrentConversationDir ||
295
- fs.existsSync(path.join(entryPath, "state.json")) ||
296
- fs.existsSync(path.join(entryPath, "events.ndjson"));
530
+ const conversationEntryPath = path.join(resolvedConversationsDir, conversationEntry.name);
531
+ return conversationEntryPath === resolvedCurrentConversationDir ||
532
+ fs.existsSync(path.join(conversationEntryPath, "state.json")) ||
533
+ fs.existsSync(path.join(conversationEntryPath, "events.ndjson"));
297
534
  });
535
+ });
298
536
  if (!looksDedicated) {
299
537
  throw new Error(`refusing to change permissions on a non-dedicated store directory; use a private 0700 directory: ${storeDir}`);
300
538
  }
301
539
  fs.chmodSync(resolvedStoreDir, PRIVATE_DIRECTORY_MODE);
302
540
  }
541
+ function prepareStoreRootForWriterLock(storeDir) {
542
+ const resolvedStoreDir = path.resolve(storeDir);
543
+ if (!fs.existsSync(resolvedStoreDir)) {
544
+ fs.mkdirSync(resolvedStoreDir, {
545
+ recursive: true,
546
+ mode: PRIVATE_DIRECTORY_MODE
547
+ });
548
+ }
549
+ assertNotSymlink(resolvedStoreDir, "store directory");
550
+ const stat = fs.lstatSync(resolvedStoreDir);
551
+ if (!stat.isDirectory()) {
552
+ throw new Error(`store directory must be a real directory: ${storeDir}`);
553
+ }
554
+ // This preliminary, non-mutating check prevents a bad custom --store-dir
555
+ // from being chmodded or receiving a lock file before it fails closed.
556
+ assertWritableCompatibility(inspectStoreCompatibility(resolvedStoreDir));
557
+ }
558
+ function ensureStoreWritableWhileLocked(storeDir) {
559
+ let compatibility = inspectStoreCompatibility(storeDir);
560
+ if (compatibility.status === "uninitialized") {
561
+ createStoreManifest(storeDir);
562
+ compatibility = inspectStoreCompatibility(storeDir);
563
+ }
564
+ assertWritableCompatibility(compatibility);
565
+ // Permission repair is a write and therefore happens only after the
566
+ // manifest has been validated under the root writer lock.
567
+ fs.chmodSync(storeDir, PRIVATE_DIRECTORY_MODE);
568
+ ensureDir(storeConversationsDir(storeDir));
569
+ return readStoreManifest(storeManifestPath(storeDir));
570
+ }
571
+ function assertWritableCompatibility(compatibility) {
572
+ if (!compatibility.writable) {
573
+ throw new StoreCompatibilityError(`${compatibility.reason}; refusing to mutate the AKK store`, compatibility);
574
+ }
575
+ }
576
+ function assertSameStoreLease(lease, requestedStoreDir) {
577
+ if (path.resolve(lease.storeDir) !== path.resolve(requestedStoreDir)) {
578
+ throw new Error(`cannot acquire AKK store writer lease for ${requestedStoreDir} while holding ${lease.storeDir}`);
579
+ }
580
+ }
581
+ function revalidateStoreWriterLease(lease) {
582
+ const compatibility = inspectStoreCompatibility(lease.storeDir);
583
+ assertWritableCompatibility(compatibility);
584
+ if (compatibility.format_version !== lease.manifest.format_version ||
585
+ compatibility.writer_protocol !== lease.manifest.writer_protocol) {
586
+ throw new StoreCompatibilityError("AKK store manifest changed while its writer lease was active", compatibility);
587
+ }
588
+ }
589
+ function storeHasConversationData(storeDir) {
590
+ const conversationsDir = storeConversationsDir(storeDir);
591
+ if (!fs.existsSync(conversationsDir)) {
592
+ return fs.readdirSync(storeDir).some((entry) => !isStoreInitializationEntry(entry));
593
+ }
594
+ assertNotSymlink(conversationsDir, "conversations directory");
595
+ const conversationsStat = fs.lstatSync(conversationsDir);
596
+ if (!conversationsStat.isDirectory()) {
597
+ return true;
598
+ }
599
+ return fs.readdirSync(conversationsDir).length > 0 ||
600
+ fs.readdirSync(storeDir).some((entry) => entry !== STORE_CONVERSATIONS_DIRECTORY &&
601
+ !isStoreInitializationEntry(entry));
602
+ }
603
+ function isStoreInitializationEntry(entry) {
604
+ return entry === STORE_MANIFEST_FILE ||
605
+ entry === STORE_RUNTIME_DIRECTORY ||
606
+ entry === STORE_WRITER_LOCK_FILE ||
607
+ entry === `${STORE_WRITER_LOCK_FILE}${STORE_LOCK_RECLAIM_SUFFIX}` ||
608
+ (entry.startsWith(STORE_MANIFEST_TEMP_PREFIX) &&
609
+ entry.endsWith(STORE_MANIFEST_TEMP_SUFFIX));
610
+ }
611
+ function createStoreManifest(storeDir) {
612
+ if (storeHasConversationData(storeDir)) {
613
+ const compatibility = inspectStoreCompatibility(storeDir);
614
+ throw new StoreCompatibilityError("refusing to adopt a non-empty manifestless AKK store; choose an empty store directory", compatibility);
615
+ }
616
+ const manifestPath = storeManifestPath(storeDir);
617
+ const manifest = {
618
+ schema: STORE_SCHEMA,
619
+ format_version: STORE_FORMAT_VERSION,
620
+ writer_protocol: STORE_WRITER_PROTOCOL,
621
+ created_at: new Date().toISOString()
622
+ };
623
+ const tempPath = path.join(storeDir, `${STORE_MANIFEST_TEMP_PREFIX}${process.pid}.${randomUUID()}${STORE_MANIFEST_TEMP_SUFFIX}`);
624
+ let fd;
625
+ try {
626
+ fd = fs.openSync(tempPath, fs.constants.O_CREAT |
627
+ fs.constants.O_EXCL |
628
+ fs.constants.O_WRONLY |
629
+ NO_FOLLOW_FLAG, PRIVATE_FILE_MODE);
630
+ fs.fchmodSync(fd, PRIVATE_FILE_MODE);
631
+ fs.writeFileSync(fd, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
632
+ fs.fsyncSync(fd);
633
+ fs.closeSync(fd);
634
+ fd = undefined;
635
+ // linkSync publishes the already-fsynced inode without ever exposing a
636
+ // partially written manifest and, unlike rename, never replaces one.
637
+ fs.linkSync(tempPath, manifestPath);
638
+ fs.unlinkSync(tempPath);
639
+ fsyncDirectory(storeDir);
640
+ }
641
+ catch (error) {
642
+ if (fd !== undefined) {
643
+ fs.closeSync(fd);
644
+ }
645
+ try {
646
+ fs.unlinkSync(tempPath);
647
+ }
648
+ catch (cleanupError) {
649
+ if (!isNodeError(cleanupError, "ENOENT")) {
650
+ throw cleanupError;
651
+ }
652
+ }
653
+ if (isNodeError(error, "EEXIST")) {
654
+ return;
655
+ }
656
+ throw error;
657
+ }
658
+ }
659
+ function readStoreManifest(manifestPath) {
660
+ const fd = openRegularFileNoFollow(manifestPath, fs.constants.O_RDONLY, "store manifest");
661
+ try {
662
+ const parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
663
+ if (parsed.schema !== STORE_SCHEMA ||
664
+ !Number.isSafeInteger(parsed.format_version) ||
665
+ !Number.isSafeInteger(parsed.writer_protocol) ||
666
+ typeof parsed.created_at !== "string") {
667
+ throw new Error(`invalid AKK store manifest: ${manifestPath}`);
668
+ }
669
+ return parsed;
670
+ }
671
+ finally {
672
+ fs.closeSync(fd);
673
+ }
674
+ }
303
675
  function assertNotSymlink(targetPath, label) {
304
676
  let stat;
305
677
  try {