@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/CHANGELOG.md +13 -0
- package/README.md +16 -4
- package/dist/src/cli.js +570 -407
- package/dist/src/cli.js.map +1 -1
- package/dist/src/openclaw-plugin-helpers.js +2 -1
- package/dist/src/openclaw-plugin-helpers.js.map +1 -1
- package/dist/src/openclaw-plugin.d.ts +1 -0
- package/dist/src/openclaw-plugin.js +167 -157
- package/dist/src/openclaw-plugin.js.map +1 -1
- package/dist/src/store.d.ts +50 -0
- package/dist/src/store.js +414 -42
- package/dist/src/store.js.map +1 -1
- package/openclaw.plugin.json +2 -6
- package/package.json +1 -1
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", "
|
|
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
|
|
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:
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
126
|
-
return fs.readdirSync(
|
|
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,
|
|
129
|
-
.filter((
|
|
130
|
-
.map((
|
|
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
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
+
throw new Error(`event conversation_id does not match its directory: ${logPath}`);
|
|
262
470
|
}
|
|
263
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
294
|
-
return
|
|
295
|
-
fs.existsSync(path.join(
|
|
296
|
-
fs.existsSync(path.join(
|
|
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 {
|