@scotthuang/agent-knock-knock 0.8.1 → 0.10.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 +42 -0
- package/README.md +45 -17
- package/dist/src/agent-session-provider.d.ts +13 -0
- package/dist/src/claude-local-transcript-provider.d.ts +29 -1
- package/dist/src/claude-local-transcript-provider.js +313 -0
- package/dist/src/claude-local-transcript-provider.js.map +1 -1
- package/dist/src/claude-terminal-agent-adapter.d.ts +6 -1
- package/dist/src/claude-terminal-agent-adapter.js +178 -0
- package/dist/src/claude-terminal-agent-adapter.js.map +1 -1
- package/dist/src/cli.js +5671 -317
- package/dist/src/cli.js.map +1 -1
- package/dist/src/codex-local-session-provider.d.ts +3 -1
- package/dist/src/codex-local-session-provider.js +17 -0
- package/dist/src/codex-local-session-provider.js.map +1 -1
- package/dist/src/codex-store-adapter.d.ts +26 -1
- package/dist/src/codex-store-adapter.js +575 -2
- package/dist/src/codex-store-adapter.js.map +1 -1
- package/dist/src/codex-terminal-agent-adapter.d.ts +6 -1
- package/dist/src/codex-terminal-agent-adapter.js +192 -0
- package/dist/src/codex-terminal-agent-adapter.js.map +1 -1
- package/dist/src/managed-session.d.ts +163 -0
- package/dist/src/managed-session.js +873 -0
- package/dist/src/managed-session.js.map +1 -0
- package/dist/src/native-thread-lifecycle-policy.d.ts +57 -0
- package/dist/src/native-thread-lifecycle-policy.js +116 -0
- package/dist/src/native-thread-lifecycle-policy.js.map +1 -0
- package/dist/src/openclaw-plugin-helpers.d.ts +45 -12
- package/dist/src/openclaw-plugin-helpers.js +386 -47
- package/dist/src/openclaw-plugin-helpers.js.map +1 -1
- package/dist/src/openclaw-plugin.js +814 -144
- package/dist/src/openclaw-plugin.js.map +1 -1
- package/dist/src/protocol.d.ts +28 -1
- package/dist/src/protocol.js +93 -4
- package/dist/src/protocol.js.map +1 -1
- package/dist/src/session-store.d.ts +57 -0
- package/dist/src/session-store.js +529 -0
- package/dist/src/session-store.js.map +1 -0
- package/dist/src/store.d.ts +5 -3
- package/dist/src/store.js +422 -17
- package/dist/src/store.js.map +1 -1
- package/dist/src/terminal-agent-adapter.d.ts +175 -0
- package/dist/src/terminal-agent-adapter.js +12 -0
- package/dist/src/terminal-agent-adapter.js.map +1 -1
- package/dist/src/terminal-agent-bridge.d.ts +9 -0
- package/dist/src/terminal-agent-bridge.js +17 -1
- package/dist/src/terminal-agent-bridge.js.map +1 -1
- package/dist/src/terminal-control-provider.d.ts +2 -0
- package/dist/src/terminal-control-provider.js +1 -0
- package/dist/src/terminal-control-provider.js.map +1 -1
- package/docs/quickstart-tmux.md +23 -2
- package/openclaw.plugin.json +19 -3
- package/package.json +1 -1
- package/templates/openclaw-skills/agent-knock-knock/SKILL.md +42 -17
package/dist/src/store.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
+
import { sessionIdForConversation, turnIdForConversation, validateMessage } from "./protocol.js";
|
|
7
|
+
import { assertManagedSessionState, managedSessionStatesFromConversations, managedSessionStorageKey } from "./managed-session.js";
|
|
6
8
|
const PRIVATE_DIRECTORY_MODE = 0o700;
|
|
7
9
|
const PRIVATE_FILE_MODE = 0o600;
|
|
8
10
|
const STORE_SCHEMA = "agent-knock-knock/store";
|
|
9
11
|
const STORE_MANIFEST_FILE = "manifest.json";
|
|
10
12
|
const STORE_CONVERSATIONS_DIRECTORY = "conversations";
|
|
11
13
|
const STORE_RUNTIME_DIRECTORY = "runtime";
|
|
14
|
+
const STORE_SESSIONS_DIRECTORY = "sessions";
|
|
15
|
+
const STORE_TRANSITIONS_DIRECTORY = "transitions";
|
|
12
16
|
const STORE_WRITER_LOCK_FILE = ".akk-writer.lock";
|
|
13
17
|
const STORE_MANIFEST_TEMP_PREFIX = `.${STORE_MANIFEST_FILE}.`;
|
|
14
18
|
const STORE_MANIFEST_TEMP_SUFFIX = ".tmp";
|
|
@@ -21,7 +25,8 @@ const NO_FOLLOW_FLAG = typeof fs.constants.O_NOFOLLOW === "number"
|
|
|
21
25
|
? fs.constants.O_NOFOLLOW
|
|
22
26
|
: 0;
|
|
23
27
|
export const STORE_FORMAT_VERSION = 1;
|
|
24
|
-
export const STORE_WRITER_PROTOCOL =
|
|
28
|
+
export const STORE_WRITER_PROTOCOL = 3;
|
|
29
|
+
const STORE_UPGRADEABLE_WRITER_PROTOCOLS = new Set([1, 2]);
|
|
25
30
|
const STORE_WRITER_LEASE_BRAND = Symbol("akk-store-writer-lease");
|
|
26
31
|
const activeStoreWriterLease = new AsyncLocalStorage();
|
|
27
32
|
export class StoreCompatibilityError extends Error {
|
|
@@ -41,8 +46,46 @@ export function defaultLogDir(workspace = process.cwd()) {
|
|
|
41
46
|
}
|
|
42
47
|
export function ensureDir(dir) {
|
|
43
48
|
const resolvedDir = path.resolve(dir);
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
const missing = [];
|
|
50
|
+
let cursor = resolvedDir;
|
|
51
|
+
while (true) {
|
|
52
|
+
try {
|
|
53
|
+
const existing = fs.lstatSync(cursor);
|
|
54
|
+
if (existing.isSymbolicLink() || !existing.isDirectory()) {
|
|
55
|
+
throw new Error(`store directory must be a real directory, not a symlink: ${cursor}`);
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (!isNodeError(error, "ENOENT")) {
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
missing.unshift(cursor);
|
|
64
|
+
const parent = path.dirname(cursor);
|
|
65
|
+
if (parent === cursor) {
|
|
66
|
+
throw new Error(`could not find an existing parent directory for ${dir}`);
|
|
67
|
+
}
|
|
68
|
+
cursor = parent;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const directory of missing) {
|
|
72
|
+
try {
|
|
73
|
+
fs.mkdirSync(directory, { mode: PRIVATE_DIRECTORY_MODE });
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (!isNodeError(error, "EEXIST")) {
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const created = fs.lstatSync(directory);
|
|
81
|
+
if (created.isSymbolicLink() || !created.isDirectory()) {
|
|
82
|
+
throw new Error(`store directory must be a real directory, not a symlink: ${directory}`);
|
|
83
|
+
}
|
|
84
|
+
fs.chmodSync(directory, PRIVATE_DIRECTORY_MODE);
|
|
85
|
+
// Persist each newly created directory entry before a child entry can be
|
|
86
|
+
// treated as durable. This also covers a recursively new custom Store path.
|
|
87
|
+
fsyncDirectory(path.dirname(directory));
|
|
88
|
+
}
|
|
46
89
|
const stat = fs.lstatSync(resolvedDir);
|
|
47
90
|
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
48
91
|
throw new Error(`store directory must be a real directory, not a symlink: ${dir}`);
|
|
@@ -55,6 +98,9 @@ export function storeManifestPath(storeDir = defaultStoreDir()) {
|
|
|
55
98
|
export function storeConversationsDir(storeDir = defaultStoreDir()) {
|
|
56
99
|
return path.join(storeDir, STORE_CONVERSATIONS_DIRECTORY);
|
|
57
100
|
}
|
|
101
|
+
export function storeSessionsDir(storeDir = defaultStoreDir()) {
|
|
102
|
+
return path.join(storeDir, STORE_SESSIONS_DIRECTORY);
|
|
103
|
+
}
|
|
58
104
|
export function inspectStoreCompatibility(storeDir = defaultStoreDir()) {
|
|
59
105
|
const resolvedStoreDir = path.resolve(storeDir);
|
|
60
106
|
const manifestPath = storeManifestPath(resolvedStoreDir);
|
|
@@ -72,7 +118,16 @@ export function inspectStoreCompatibility(storeDir = defaultStoreDir()) {
|
|
|
72
118
|
if (!storeStat.isDirectory()) {
|
|
73
119
|
throw new Error(`store directory must be a real directory: ${storeDir}`);
|
|
74
120
|
}
|
|
75
|
-
|
|
121
|
+
let manifestStat;
|
|
122
|
+
try {
|
|
123
|
+
manifestStat = fs.lstatSync(manifestPath);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (!isNodeError(error, "ENOENT")) {
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!manifestStat) {
|
|
76
131
|
if (storeHasConversationData(resolvedStoreDir)) {
|
|
77
132
|
return {
|
|
78
133
|
status: "legacy",
|
|
@@ -91,15 +146,26 @@ export function inspectStoreCompatibility(storeDir = defaultStoreDir()) {
|
|
|
91
146
|
writable: true
|
|
92
147
|
};
|
|
93
148
|
}
|
|
149
|
+
if (manifestStat.isSymbolicLink() || !manifestStat.isFile()) {
|
|
150
|
+
throw new Error(`store manifest must be a regular file: ${manifestPath}`);
|
|
151
|
+
}
|
|
94
152
|
const manifest = readStoreManifest(manifestPath);
|
|
95
153
|
const readable = manifest.format_version === STORE_FORMAT_VERSION;
|
|
96
|
-
const
|
|
154
|
+
const compatible = readable && manifest.writer_protocol === STORE_WRITER_PROTOCOL;
|
|
155
|
+
const upgradeable = readable &&
|
|
156
|
+
STORE_UPGRADEABLE_WRITER_PROTOCOLS.has(manifest.writer_protocol);
|
|
157
|
+
const writable = compatible || upgradeable;
|
|
97
158
|
return {
|
|
98
|
-
status:
|
|
159
|
+
status: compatible
|
|
160
|
+
? "compatible"
|
|
161
|
+
: upgradeable
|
|
162
|
+
? "upgradeable"
|
|
163
|
+
: "incompatible",
|
|
99
164
|
store_dir: storeDir,
|
|
100
165
|
manifest_path: manifestPath,
|
|
101
166
|
readable,
|
|
102
167
|
writable,
|
|
168
|
+
upgradeable,
|
|
103
169
|
format_version: manifest.format_version,
|
|
104
170
|
writer_protocol: manifest.writer_protocol,
|
|
105
171
|
...(!readable
|
|
@@ -242,13 +308,17 @@ export function logPathForStatePath(statePath) {
|
|
|
242
308
|
return statePath.replace(/\.state\.json$/, ".ndjson");
|
|
243
309
|
}
|
|
244
310
|
export function saveState(statePath, conversation) {
|
|
311
|
+
// Validate the state identity before acquiring a writer lease. In particular,
|
|
312
|
+
// a malformed state must not initialize or upgrade the Store manifest.
|
|
313
|
+
assertConversationStateIdentity(conversation);
|
|
245
314
|
const paths = assertCanonicalConversationDataPath(statePath, "state.json");
|
|
315
|
+
assertConversationStateIdentity(conversation, path.basename(paths.conversationDir));
|
|
316
|
+
assertConversationStorageMetadata(statePath, conversation);
|
|
246
317
|
withStoreWriterLease(paths.storeDir, () => {
|
|
247
318
|
saveStateWithWriterLease(statePath, conversation);
|
|
248
319
|
});
|
|
249
320
|
}
|
|
250
321
|
function saveStateWithWriterLease(statePath, conversation) {
|
|
251
|
-
validateConversationId(conversation.conversation_id);
|
|
252
322
|
secureConversationStorageMetadata(statePath, conversation);
|
|
253
323
|
prepareDataDirectory(statePath);
|
|
254
324
|
const serialized = `${JSON.stringify(conversation, null, 2)}\n`;
|
|
@@ -289,7 +359,7 @@ export function loadState(statePath) {
|
|
|
289
359
|
assertNotSymlink(path.dirname(statePath), "conversation directory");
|
|
290
360
|
const fd = openRegularFileNoFollow(statePath, fs.constants.O_RDONLY, "state file");
|
|
291
361
|
try {
|
|
292
|
-
return JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
362
|
+
return withConversationIdentity(JSON.parse(fs.readFileSync(fd, "utf8")));
|
|
293
363
|
}
|
|
294
364
|
finally {
|
|
295
365
|
fs.closeSync(fd);
|
|
@@ -336,6 +406,31 @@ function withCanonicalConversationStorage(conversation, paths) {
|
|
|
336
406
|
event_log_path: path.resolve(paths.logPath)
|
|
337
407
|
};
|
|
338
408
|
}
|
|
409
|
+
function withConversationIdentity(conversation) {
|
|
410
|
+
const { sessionId, turnId } = assertConversationStateIdentity(conversation);
|
|
411
|
+
return {
|
|
412
|
+
...conversation,
|
|
413
|
+
session_id: sessionId,
|
|
414
|
+
turn_id: turnId
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function assertConversationStateIdentity(value, expectedConversationId) {
|
|
418
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
419
|
+
throw new Error("conversation state must be an object");
|
|
420
|
+
}
|
|
421
|
+
const conversation = value;
|
|
422
|
+
validateConversationId(conversation.conversation_id);
|
|
423
|
+
const sessionId = sessionIdForConversation(conversation);
|
|
424
|
+
const turnId = turnIdForConversation(conversation);
|
|
425
|
+
if (turnId !== conversation.conversation_id) {
|
|
426
|
+
throw new Error(`conversation turn_id ${turnId} does not match its store identity ${conversation.conversation_id}`);
|
|
427
|
+
}
|
|
428
|
+
if (expectedConversationId !== undefined &&
|
|
429
|
+
conversation.conversation_id !== expectedConversationId) {
|
|
430
|
+
throw new Error(`conversation id ${conversation.conversation_id} does not match its store directory ${expectedConversationId}`);
|
|
431
|
+
}
|
|
432
|
+
return { conversation, sessionId, turnId };
|
|
433
|
+
}
|
|
339
434
|
export function appendEvent(logPath, event) {
|
|
340
435
|
const paths = assertCanonicalConversationDataPath(logPath, "events.ndjson");
|
|
341
436
|
withStoreWriterLease(paths.storeDir, () => {
|
|
@@ -444,6 +539,12 @@ function prepareDataDirectory(dataPath) {
|
|
|
444
539
|
}
|
|
445
540
|
}
|
|
446
541
|
function secureConversationStorageMetadata(statePath, conversation) {
|
|
542
|
+
const paths = assertConversationStorageMetadata(statePath, conversation);
|
|
543
|
+
ensureStoreWritable(paths.storeDir);
|
|
544
|
+
ensureStoreDir(paths.storeDir, paths.conversationDir);
|
|
545
|
+
ensureDir(paths.conversationDir);
|
|
546
|
+
}
|
|
547
|
+
function assertConversationStorageMetadata(statePath, conversation) {
|
|
447
548
|
if (typeof conversation.store_dir !== "string" ||
|
|
448
549
|
typeof conversation.conversation_dir !== "string" ||
|
|
449
550
|
typeof conversation.state_path !== "string") {
|
|
@@ -455,9 +556,7 @@ function secureConversationStorageMetadata(statePath, conversation) {
|
|
|
455
556
|
path.resolve(paths.statePath) !== path.resolve(statePath)) {
|
|
456
557
|
throw new Error(`conversation storage metadata does not match state path: ${statePath}`);
|
|
457
558
|
}
|
|
458
|
-
|
|
459
|
-
ensureStoreDir(paths.storeDir, paths.conversationDir);
|
|
460
|
-
ensureDir(paths.conversationDir);
|
|
559
|
+
return paths;
|
|
461
560
|
}
|
|
462
561
|
function secureEventStorageMetadata(logPath, event) {
|
|
463
562
|
if (typeof event.conversation_id !== "string") {
|
|
@@ -495,6 +594,132 @@ function assertCanonicalConversationDataPath(dataPath, expectedBasename) {
|
|
|
495
594
|
}
|
|
496
595
|
return canonical;
|
|
497
596
|
}
|
|
597
|
+
function managedSessionStatePath(storeDir, sessionId) {
|
|
598
|
+
return path.join(storeSessionsDir(storeDir), managedSessionStorageKey(sessionId), "state.json");
|
|
599
|
+
}
|
|
600
|
+
function tryReadMaterializedManagedSession(statePath, expectedSessionId) {
|
|
601
|
+
try {
|
|
602
|
+
const fd = openRegularFileNoFollow(statePath, fs.constants.O_RDONLY, "managed session state");
|
|
603
|
+
try {
|
|
604
|
+
const parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
605
|
+
assertManagedSessionState(parsed, expectedSessionId);
|
|
606
|
+
return parsed;
|
|
607
|
+
}
|
|
608
|
+
finally {
|
|
609
|
+
fs.closeSync(fd);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
if (isNodeError(error, "ENOENT")) {
|
|
614
|
+
return undefined;
|
|
615
|
+
}
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
function materializeManagedSessionStatesWhileLocked(storeDir, states, replaceMigrationRevisionOne) {
|
|
620
|
+
const sessionsDir = storeSessionsDir(storeDir);
|
|
621
|
+
assertNotSymlink(sessionsDir, "managed sessions directory");
|
|
622
|
+
ensureDir(sessionsDir);
|
|
623
|
+
const writes = [];
|
|
624
|
+
for (const state of states) {
|
|
625
|
+
assertManagedSessionState(state, state.session_id);
|
|
626
|
+
const statePath = managedSessionStatePath(storeDir, state.session_id);
|
|
627
|
+
const existing = tryReadMaterializedManagedSession(statePath, state.session_id);
|
|
628
|
+
if (existing && JSON.stringify(existing) === JSON.stringify(state)) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
if (existing &&
|
|
632
|
+
!(replaceMigrationRevisionOne &&
|
|
633
|
+
existing.revision === 1 &&
|
|
634
|
+
existing.lineage.created_by === "migration")) {
|
|
635
|
+
throw new Error(`managed Session ${state.session_id} already has non-migration state; ` +
|
|
636
|
+
"refusing to overwrite it during Store materialization");
|
|
637
|
+
}
|
|
638
|
+
writes.push({ path: statePath, state });
|
|
639
|
+
}
|
|
640
|
+
for (const write of writes) {
|
|
641
|
+
atomicSaveManagedSessionState(write.path, write.state);
|
|
642
|
+
}
|
|
643
|
+
// Record-directory creation and every state rename are durable before a
|
|
644
|
+
// protocol-3 manifest can become visible.
|
|
645
|
+
fsyncDirectory(sessionsDir);
|
|
646
|
+
fsyncDirectory(storeDir);
|
|
647
|
+
}
|
|
648
|
+
function assertUpgradeableManagedSessionTree(storeDir, expectedStates) {
|
|
649
|
+
const sessionsDir = storeSessionsDir(storeDir);
|
|
650
|
+
let rootStat;
|
|
651
|
+
try {
|
|
652
|
+
rootStat = fs.lstatSync(sessionsDir);
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
if (isNodeError(error, "ENOENT")) {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
throw error;
|
|
659
|
+
}
|
|
660
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
661
|
+
throw new Error(`managed sessions directory must be a real directory: ${sessionsDir}`);
|
|
662
|
+
}
|
|
663
|
+
const expectedByKey = new Map(expectedStates.map((state) => [managedSessionStorageKey(state.session_id), state]));
|
|
664
|
+
for (const entry of fs.readdirSync(sessionsDir, { withFileTypes: true })) {
|
|
665
|
+
const entryPath = path.join(sessionsDir, entry.name);
|
|
666
|
+
if (entry.isSymbolicLink() ||
|
|
667
|
+
!entry.isDirectory() ||
|
|
668
|
+
!/^[0-9a-f]{64}$/u.test(entry.name)) {
|
|
669
|
+
throw new Error(`predecessor Store contains an invalid managed Session entry: ${entryPath}`);
|
|
670
|
+
}
|
|
671
|
+
const expected = expectedByKey.get(entry.name);
|
|
672
|
+
if (!expected) {
|
|
673
|
+
throw new Error(`predecessor Store contains unexpected managed Session state: ${entryPath}`);
|
|
674
|
+
}
|
|
675
|
+
const statePath = path.join(entryPath, "state.json");
|
|
676
|
+
// An expected empty record directory is a recoverable crash residue. Any
|
|
677
|
+
// present state must already be a strict, correctly keyed Session record.
|
|
678
|
+
tryReadMaterializedManagedSession(statePath, expected.session_id);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
function atomicSaveManagedSessionState(statePath, state) {
|
|
682
|
+
const recordDir = path.dirname(statePath);
|
|
683
|
+
const sessionsDir = path.dirname(recordDir);
|
|
684
|
+
assertNotSymlink(sessionsDir, "managed sessions directory");
|
|
685
|
+
ensureDir(recordDir);
|
|
686
|
+
assertNotSymlink(recordDir, "managed session directory");
|
|
687
|
+
const recordStat = fs.lstatSync(recordDir);
|
|
688
|
+
if (!recordStat.isDirectory()) {
|
|
689
|
+
throw new Error(`managed session directory must be a real directory: ${recordDir}`);
|
|
690
|
+
}
|
|
691
|
+
assertWritableDataPath(statePath, "managed session state");
|
|
692
|
+
const tempPath = path.join(recordDir, `.state.json.${process.pid}.${randomUUID()}.tmp`);
|
|
693
|
+
let fd;
|
|
694
|
+
try {
|
|
695
|
+
fd = fs.openSync(tempPath, fs.constants.O_CREAT |
|
|
696
|
+
fs.constants.O_EXCL |
|
|
697
|
+
fs.constants.O_WRONLY |
|
|
698
|
+
NO_FOLLOW_FLAG, PRIVATE_FILE_MODE);
|
|
699
|
+
fs.fchmodSync(fd, PRIVATE_FILE_MODE);
|
|
700
|
+
fs.writeFileSync(fd, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
701
|
+
fs.fsyncSync(fd);
|
|
702
|
+
fs.closeSync(fd);
|
|
703
|
+
fd = undefined;
|
|
704
|
+
assertWritableDataPath(statePath, "managed session state");
|
|
705
|
+
fs.renameSync(tempPath, statePath);
|
|
706
|
+
fs.chmodSync(statePath, PRIVATE_FILE_MODE);
|
|
707
|
+
fsyncDirectory(recordDir);
|
|
708
|
+
}
|
|
709
|
+
finally {
|
|
710
|
+
if (fd !== undefined) {
|
|
711
|
+
fs.closeSync(fd);
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
fs.unlinkSync(tempPath);
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
if (!isNodeError(error, "ENOENT")) {
|
|
718
|
+
throw error;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
498
723
|
function ensureStoreDir(storeDir, currentConversationDir) {
|
|
499
724
|
const resolvedStoreDir = path.resolve(storeDir);
|
|
500
725
|
assertNotSymlink(resolvedStoreDir, "store directory");
|
|
@@ -519,6 +744,11 @@ function ensureStoreDir(storeDir, currentConversationDir) {
|
|
|
519
744
|
if (entry.name === STORE_RUNTIME_DIRECTORY && entry.isDirectory()) {
|
|
520
745
|
return true;
|
|
521
746
|
}
|
|
747
|
+
if ((entry.name === STORE_SESSIONS_DIRECTORY ||
|
|
748
|
+
entry.name === STORE_TRANSITIONS_DIRECTORY) &&
|
|
749
|
+
entry.isDirectory()) {
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
522
752
|
if (entryPath !== resolvedConversationsDir || !entry.isDirectory()) {
|
|
523
753
|
return false;
|
|
524
754
|
}
|
|
@@ -541,10 +771,7 @@ function ensureStoreDir(storeDir, currentConversationDir) {
|
|
|
541
771
|
function prepareStoreRootForWriterLock(storeDir) {
|
|
542
772
|
const resolvedStoreDir = path.resolve(storeDir);
|
|
543
773
|
if (!fs.existsSync(resolvedStoreDir)) {
|
|
544
|
-
|
|
545
|
-
recursive: true,
|
|
546
|
-
mode: PRIVATE_DIRECTORY_MODE
|
|
547
|
-
});
|
|
774
|
+
ensureDir(resolvedStoreDir);
|
|
548
775
|
}
|
|
549
776
|
assertNotSymlink(resolvedStoreDir, "store directory");
|
|
550
777
|
const stat = fs.lstatSync(resolvedStoreDir);
|
|
@@ -561,11 +788,16 @@ function ensureStoreWritableWhileLocked(storeDir) {
|
|
|
561
788
|
createStoreManifest(storeDir);
|
|
562
789
|
compatibility = inspectStoreCompatibility(storeDir);
|
|
563
790
|
}
|
|
791
|
+
else if (compatibility.status === "upgradeable") {
|
|
792
|
+
upgradeStoreWriterProtocolWhileLocked(storeDir);
|
|
793
|
+
compatibility = inspectStoreCompatibility(storeDir);
|
|
794
|
+
}
|
|
564
795
|
assertWritableCompatibility(compatibility);
|
|
565
796
|
// Permission repair is a write and therefore happens only after the
|
|
566
797
|
// manifest has been validated under the root writer lock.
|
|
567
798
|
fs.chmodSync(storeDir, PRIVATE_DIRECTORY_MODE);
|
|
568
799
|
ensureDir(storeConversationsDir(storeDir));
|
|
800
|
+
ensureDir(storeSessionsDir(storeDir));
|
|
569
801
|
return readStoreManifest(storeManifestPath(storeDir));
|
|
570
802
|
}
|
|
571
803
|
function assertWritableCompatibility(compatibility) {
|
|
@@ -656,9 +888,175 @@ function createStoreManifest(storeDir) {
|
|
|
656
888
|
throw error;
|
|
657
889
|
}
|
|
658
890
|
}
|
|
891
|
+
/** Upgrade the one explicitly supported predecessor while holding the root lock. */
|
|
892
|
+
function upgradeStoreWriterProtocolWhileLocked(storeDir) {
|
|
893
|
+
const manifestPath = storeManifestPath(storeDir);
|
|
894
|
+
const previous = readStoreManifestSnapshot(manifestPath);
|
|
895
|
+
assertUpgradeableStoreManifest(previous.manifest, storeDir);
|
|
896
|
+
// Protocol 3 makes Session state the routing/binding authority. Validate all
|
|
897
|
+
// predecessor Turns, derive every Session without using mutable Turn
|
|
898
|
+
// recency, and durably materialize those records before publishing the new
|
|
899
|
+
// manifest. Turn files and event logs remain byte-for-byte untouched.
|
|
900
|
+
const stateSnapshots = readUpgradeableStoreStateSnapshots(storeDir);
|
|
901
|
+
const sessions = managedSessionStatesFromConversations(stateSnapshots.map(({ conversation }) => conversation));
|
|
902
|
+
assertUpgradeableManagedSessionTree(storeDir, sessions);
|
|
903
|
+
materializeManagedSessionStatesWhileLocked(storeDir, sessions, true);
|
|
904
|
+
const upgraded = {
|
|
905
|
+
...previous.manifest,
|
|
906
|
+
writer_protocol: STORE_WRITER_PROTOCOL,
|
|
907
|
+
created_at: previous.manifest.created_at
|
|
908
|
+
};
|
|
909
|
+
const tempPath = path.join(storeDir, `${STORE_MANIFEST_TEMP_PREFIX}${process.pid}.${randomUUID()}${STORE_MANIFEST_TEMP_SUFFIX}`);
|
|
910
|
+
let fd;
|
|
911
|
+
try {
|
|
912
|
+
fd = fs.openSync(tempPath, fs.constants.O_CREAT |
|
|
913
|
+
fs.constants.O_EXCL |
|
|
914
|
+
fs.constants.O_WRONLY |
|
|
915
|
+
NO_FOLLOW_FLAG, PRIVATE_FILE_MODE);
|
|
916
|
+
fs.fchmodSync(fd, PRIVATE_FILE_MODE);
|
|
917
|
+
fs.writeFileSync(fd, `${JSON.stringify(upgraded, null, 2)}\n`, "utf8");
|
|
918
|
+
fs.fsyncSync(fd);
|
|
919
|
+
fs.closeSync(fd);
|
|
920
|
+
fd = undefined;
|
|
921
|
+
// A process that ignores the root writer lock must not trick us into
|
|
922
|
+
// replacing a different manifest. Re-check both file identity and the
|
|
923
|
+
// supported predecessor immediately before the atomic rename.
|
|
924
|
+
revalidateUpgradeableStoreStateSnapshots(storeDir, stateSnapshots);
|
|
925
|
+
const current = readStoreManifestSnapshot(manifestPath);
|
|
926
|
+
if (current.device !== previous.device ||
|
|
927
|
+
current.inode !== previous.inode ||
|
|
928
|
+
current.manifest.schema !== previous.manifest.schema ||
|
|
929
|
+
current.manifest.format_version !== previous.manifest.format_version ||
|
|
930
|
+
current.manifest.writer_protocol !== previous.manifest.writer_protocol ||
|
|
931
|
+
current.manifest.created_at !== previous.manifest.created_at) {
|
|
932
|
+
throw new Error("AKK store manifest changed while upgrading writer protocol; refusing to replace it");
|
|
933
|
+
}
|
|
934
|
+
const manifestPathStat = fs.lstatSync(manifestPath, { bigint: true });
|
|
935
|
+
if (manifestPathStat.isSymbolicLink() ||
|
|
936
|
+
!manifestPathStat.isFile() ||
|
|
937
|
+
manifestPathStat.dev !== current.device ||
|
|
938
|
+
manifestPathStat.ino !== current.inode) {
|
|
939
|
+
throw new Error("AKK store manifest path changed while upgrading writer protocol; refusing to replace it");
|
|
940
|
+
}
|
|
941
|
+
fs.renameSync(tempPath, manifestPath);
|
|
942
|
+
fsyncDirectory(storeDir);
|
|
943
|
+
}
|
|
944
|
+
finally {
|
|
945
|
+
if (fd !== undefined) {
|
|
946
|
+
fs.closeSync(fd);
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
fs.unlinkSync(tempPath);
|
|
950
|
+
}
|
|
951
|
+
catch (error) {
|
|
952
|
+
if (!isNodeError(error, "ENOENT")) {
|
|
953
|
+
throw error;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
function assertUpgradeableStoreManifest(manifest, storeDir) {
|
|
959
|
+
if (manifest.schema !== STORE_SCHEMA ||
|
|
960
|
+
manifest.format_version !== STORE_FORMAT_VERSION ||
|
|
961
|
+
!STORE_UPGRADEABLE_WRITER_PROTOCOLS.has(manifest.writer_protocol)) {
|
|
962
|
+
throw new StoreCompatibilityError(`store is not one of the supported writer protocol predecessors ${[
|
|
963
|
+
...STORE_UPGRADEABLE_WRITER_PROTOCOLS
|
|
964
|
+
].join(", ")}`, inspectStoreCompatibility(storeDir));
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
function readUpgradeableStoreStateSnapshots(storeDir) {
|
|
968
|
+
const conversationsDir = storeConversationsDir(storeDir);
|
|
969
|
+
let conversationsStat;
|
|
970
|
+
try {
|
|
971
|
+
conversationsStat = fs.lstatSync(conversationsDir);
|
|
972
|
+
}
|
|
973
|
+
catch (error) {
|
|
974
|
+
if (isNodeError(error, "ENOENT")) {
|
|
975
|
+
return [];
|
|
976
|
+
}
|
|
977
|
+
throw error;
|
|
978
|
+
}
|
|
979
|
+
if (conversationsStat.isSymbolicLink() || !conversationsStat.isDirectory()) {
|
|
980
|
+
throw new Error(`conversations directory must be a real directory: ${conversationsDir}`);
|
|
981
|
+
}
|
|
982
|
+
const snapshots = [];
|
|
983
|
+
for (const entry of fs.readdirSync(conversationsDir, { withFileTypes: true })) {
|
|
984
|
+
const conversationDir = path.join(conversationsDir, entry.name);
|
|
985
|
+
const conversationStat = fs.lstatSync(conversationDir);
|
|
986
|
+
if (conversationStat.isSymbolicLink()) {
|
|
987
|
+
throw new Error(`conversation directory must not be a symlink: ${conversationDir}`);
|
|
988
|
+
}
|
|
989
|
+
if (!conversationStat.isDirectory()) {
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
const statePath = path.join(conversationDir, "state.json");
|
|
993
|
+
let stateStat;
|
|
994
|
+
try {
|
|
995
|
+
stateStat = fs.lstatSync(statePath);
|
|
996
|
+
}
|
|
997
|
+
catch (error) {
|
|
998
|
+
if (isNodeError(error, "ENOENT")) {
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
throw error;
|
|
1002
|
+
}
|
|
1003
|
+
if (stateStat.isSymbolicLink() || !stateStat.isFile()) {
|
|
1004
|
+
throw new Error(`state file must be a regular file: ${statePath}`);
|
|
1005
|
+
}
|
|
1006
|
+
const stateFd = openRegularFileNoFollow(statePath, fs.constants.O_RDONLY, "state file");
|
|
1007
|
+
try {
|
|
1008
|
+
let parsed;
|
|
1009
|
+
let contents;
|
|
1010
|
+
try {
|
|
1011
|
+
contents = fs.readFileSync(stateFd, "utf8");
|
|
1012
|
+
parsed = JSON.parse(contents);
|
|
1013
|
+
}
|
|
1014
|
+
catch (error) {
|
|
1015
|
+
throw new Error(`invalid conversation state during Store upgrade: ${statePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1016
|
+
}
|
|
1017
|
+
const identity = assertConversationStateIdentity(parsed, entry.name);
|
|
1018
|
+
const stat = fs.fstatSync(stateFd, { bigint: true });
|
|
1019
|
+
snapshots.push({
|
|
1020
|
+
conversation: {
|
|
1021
|
+
...identity.conversation,
|
|
1022
|
+
session_id: identity.sessionId,
|
|
1023
|
+
turn_id: identity.turnId
|
|
1024
|
+
},
|
|
1025
|
+
statePath,
|
|
1026
|
+
device: stat.dev,
|
|
1027
|
+
inode: stat.ino,
|
|
1028
|
+
contentsSha256: createHash("sha256").update(contents).digest("hex")
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
finally {
|
|
1032
|
+
fs.closeSync(stateFd);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return snapshots;
|
|
1036
|
+
}
|
|
1037
|
+
function revalidateUpgradeableStoreStateSnapshots(storeDir, snapshots) {
|
|
1038
|
+
const current = readUpgradeableStoreStateSnapshots(storeDir);
|
|
1039
|
+
const expectedByPath = new Map(snapshots.map((snapshot) => [snapshot.statePath, snapshot]));
|
|
1040
|
+
if (current.length !== snapshots.length) {
|
|
1041
|
+
throw new Error("conversation set changed during Store upgrade");
|
|
1042
|
+
}
|
|
1043
|
+
for (const actual of current) {
|
|
1044
|
+
const expected = expectedByPath.get(actual.statePath);
|
|
1045
|
+
if (!expected ||
|
|
1046
|
+
actual.device !== expected.device ||
|
|
1047
|
+
actual.inode !== expected.inode ||
|
|
1048
|
+
actual.contentsSha256 !== expected.contentsSha256) {
|
|
1049
|
+
throw new Error(`conversation state changed during Store upgrade: ${actual.statePath}`);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
659
1053
|
function readStoreManifest(manifestPath) {
|
|
1054
|
+
return readStoreManifestSnapshot(manifestPath).manifest;
|
|
1055
|
+
}
|
|
1056
|
+
function readStoreManifestSnapshot(manifestPath) {
|
|
660
1057
|
const fd = openRegularFileNoFollow(manifestPath, fs.constants.O_RDONLY, "store manifest");
|
|
661
1058
|
try {
|
|
1059
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
662
1060
|
const parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
663
1061
|
if (parsed.schema !== STORE_SCHEMA ||
|
|
664
1062
|
!Number.isSafeInteger(parsed.format_version) ||
|
|
@@ -666,7 +1064,11 @@ function readStoreManifest(manifestPath) {
|
|
|
666
1064
|
typeof parsed.created_at !== "string") {
|
|
667
1065
|
throw new Error(`invalid AKK store manifest: ${manifestPath}`);
|
|
668
1066
|
}
|
|
669
|
-
return
|
|
1067
|
+
return {
|
|
1068
|
+
manifest: parsed,
|
|
1069
|
+
device: stat.dev,
|
|
1070
|
+
inode: stat.ino
|
|
1071
|
+
};
|
|
670
1072
|
}
|
|
671
1073
|
finally {
|
|
672
1074
|
fs.closeSync(fd);
|
|
@@ -885,9 +1287,12 @@ function isNodeError(error, code) {
|
|
|
885
1287
|
return error instanceof Error && error.code === code;
|
|
886
1288
|
}
|
|
887
1289
|
export function messageEvent(message) {
|
|
1290
|
+
validateMessage(message);
|
|
888
1291
|
return {
|
|
889
1292
|
ts: message.ts,
|
|
890
1293
|
conversation_id: message.conversation_id,
|
|
1294
|
+
session_id: message.session_id ?? message.conversation_id,
|
|
1295
|
+
turn_id: message.turn_id ?? message.conversation_id,
|
|
891
1296
|
event: "message",
|
|
892
1297
|
from: message.from,
|
|
893
1298
|
to: message.to,
|