@evo-dev/core 0.0.1-alpha.15 → 0.0.1-alpha.17
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/config/index.js +45 -2
- package/dist/index.js +12899 -11408
- package/package.json +1 -1
- package/src/config/settings.ts +45 -7
- package/src/evolution/candidates/index.ts +50 -0
- package/src/evolution/evidence/session-memory/retention.ts +2 -1
- package/src/evolution/knowledge/change-store.ts +558 -0
- package/src/evolution/knowledge/changes.ts +453 -0
- package/src/evolution/knowledge/freshness.ts +109 -0
- package/src/evolution/knowledge/index.ts +471 -55
- package/src/evolution/knowledge/review.ts +446 -0
- package/src/evolution/processor/process.ts +5 -1
- package/src/evolution/schema.ts +29 -1
- package/src/evolution/shared.ts +108 -0
- package/src/index.ts +4 -0
- package/src/team/index.ts +6 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
appendFile,
|
|
4
|
+
mkdir,
|
|
5
|
+
readFile,
|
|
6
|
+
readdir,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
stat,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from "node:fs/promises";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { resolveEvoDevPaths } from "../../config/paths.ts";
|
|
14
|
+
import type {
|
|
15
|
+
OkfKnowledgeChangeOperation,
|
|
16
|
+
OkfKnowledgeChangeState,
|
|
17
|
+
OkfKnowledgeRuntimeDiffV1,
|
|
18
|
+
OkfKnowledgeRuntimeProjectionV1,
|
|
19
|
+
} from "./changes.ts";
|
|
20
|
+
import type { OkfKnowledgeFreshness } from "./freshness.ts";
|
|
21
|
+
import type { OkfKnowledgeEvalSet, OkfKnowledgePlanCandidate } from "./index.ts";
|
|
22
|
+
|
|
23
|
+
export interface OkfKnowledgeChangeCandidateV1 {
|
|
24
|
+
schemaVersion: 1;
|
|
25
|
+
kind: "okf-knowledge-change-candidate";
|
|
26
|
+
id: string;
|
|
27
|
+
projectKey: string;
|
|
28
|
+
runId: string;
|
|
29
|
+
operation: OkfKnowledgeChangeOperation;
|
|
30
|
+
state: OkfKnowledgeChangeState;
|
|
31
|
+
stableKey: string;
|
|
32
|
+
targetPath: string;
|
|
33
|
+
base: null | {
|
|
34
|
+
conceptId: string;
|
|
35
|
+
sourceLink: string;
|
|
36
|
+
revision: string;
|
|
37
|
+
runtimeProjection: OkfKnowledgeRuntimeProjectionV1;
|
|
38
|
+
};
|
|
39
|
+
candidate: {
|
|
40
|
+
revision: string;
|
|
41
|
+
planCandidate: OkfKnowledgePlanCandidate;
|
|
42
|
+
evalSets: OkfKnowledgeEvalSet[];
|
|
43
|
+
};
|
|
44
|
+
diff: OkfKnowledgeRuntimeDiffV1;
|
|
45
|
+
freshness: {
|
|
46
|
+
before: OkfKnowledgeFreshness | null;
|
|
47
|
+
after: OkfKnowledgeFreshness;
|
|
48
|
+
reason: string;
|
|
49
|
+
};
|
|
50
|
+
provenance: {
|
|
51
|
+
evidenceWindowId: string;
|
|
52
|
+
evidenceRefs: string[];
|
|
53
|
+
createdAt: string;
|
|
54
|
+
rawContentStored: false;
|
|
55
|
+
};
|
|
56
|
+
decision: null | {
|
|
57
|
+
state: "accepted" | "rejected" | "deferred";
|
|
58
|
+
reason: string | null;
|
|
59
|
+
decidedAt: string;
|
|
60
|
+
};
|
|
61
|
+
updatedAt: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface OkfKnowledgeChangeHistoryEntryV1 {
|
|
65
|
+
schemaVersion: 1;
|
|
66
|
+
kind: "okf-knowledge-change-history";
|
|
67
|
+
changeId: string;
|
|
68
|
+
projectKey: string;
|
|
69
|
+
operation: OkfKnowledgeChangeOperation;
|
|
70
|
+
fromState: OkfKnowledgeChangeState | null;
|
|
71
|
+
toState: OkfKnowledgeChangeState;
|
|
72
|
+
baseRevision: string | null;
|
|
73
|
+
candidateRevision: string;
|
|
74
|
+
runtimeFields: string[];
|
|
75
|
+
reason: string | null;
|
|
76
|
+
changedAt: string;
|
|
77
|
+
rawContentStored: false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class OkfKnowledgeChangeLockError extends Error {
|
|
81
|
+
readonly code = "KNOWLEDGE_CHANGE_LOCK_BUSY";
|
|
82
|
+
|
|
83
|
+
constructor() {
|
|
84
|
+
super("Knowledge change is currently being reviewed by another process.");
|
|
85
|
+
this.name = "OkfKnowledgeChangeLockError";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface KnowledgeChangePaths {
|
|
90
|
+
rootDir: string;
|
|
91
|
+
projectDir: string;
|
|
92
|
+
candidatesDir: string;
|
|
93
|
+
historyPath: string;
|
|
94
|
+
locksDir: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const KNOWLEDGE_CHANGE_LOCK_STALE_MS = 5 * 60 * 1000;
|
|
98
|
+
|
|
99
|
+
export function createOkfKnowledgeChangeCandidate(input: {
|
|
100
|
+
projectKey: string;
|
|
101
|
+
runId: string;
|
|
102
|
+
operation: OkfKnowledgeChangeOperation;
|
|
103
|
+
stableKey: string;
|
|
104
|
+
targetPath: string;
|
|
105
|
+
base: OkfKnowledgeChangeCandidateV1["base"];
|
|
106
|
+
candidateRevision: string;
|
|
107
|
+
planCandidate: OkfKnowledgePlanCandidate;
|
|
108
|
+
evalSets?: OkfKnowledgeEvalSet[];
|
|
109
|
+
diff: OkfKnowledgeRuntimeDiffV1;
|
|
110
|
+
freshness: OkfKnowledgeChangeCandidateV1["freshness"];
|
|
111
|
+
evidenceWindowId: string;
|
|
112
|
+
evidenceRefs: string[];
|
|
113
|
+
createdAt: string;
|
|
114
|
+
}): OkfKnowledgeChangeCandidateV1 {
|
|
115
|
+
const projectKey = sanitizePathSegment(input.projectKey, "project key");
|
|
116
|
+
const createdAt = normalizeTimestamp(input.createdAt);
|
|
117
|
+
const seed = [
|
|
118
|
+
projectKey,
|
|
119
|
+
input.runId,
|
|
120
|
+
input.operation,
|
|
121
|
+
input.stableKey,
|
|
122
|
+
input.base?.revision ?? "none",
|
|
123
|
+
input.candidateRevision,
|
|
124
|
+
].join("\0");
|
|
125
|
+
return {
|
|
126
|
+
schemaVersion: 1,
|
|
127
|
+
kind: "okf-knowledge-change-candidate",
|
|
128
|
+
id: `knowledge-change-${createHash("sha256").update(seed).digest("hex").slice(0, 24)}`,
|
|
129
|
+
projectKey,
|
|
130
|
+
runId: sanitizeText(input.runId),
|
|
131
|
+
operation: input.operation,
|
|
132
|
+
state: "pending",
|
|
133
|
+
stableKey: sanitizeText(input.stableKey),
|
|
134
|
+
targetPath: sanitizeTargetPath(input.targetPath),
|
|
135
|
+
base: input.base,
|
|
136
|
+
candidate: {
|
|
137
|
+
revision: input.candidateRevision,
|
|
138
|
+
planCandidate: input.planCandidate,
|
|
139
|
+
evalSets: input.evalSets ?? [],
|
|
140
|
+
},
|
|
141
|
+
diff: {
|
|
142
|
+
runtimeFields: [...new Set(input.diff.runtimeFields)].sort(),
|
|
143
|
+
metadataFields: [...new Set(input.diff.metadataFields)].sort(),
|
|
144
|
+
summary: sanitizeText(input.diff.summary),
|
|
145
|
+
},
|
|
146
|
+
freshness: {
|
|
147
|
+
before: input.freshness.before,
|
|
148
|
+
after: input.freshness.after,
|
|
149
|
+
reason: sanitizeText(input.freshness.reason),
|
|
150
|
+
},
|
|
151
|
+
provenance: {
|
|
152
|
+
evidenceWindowId: sanitizeText(input.evidenceWindowId),
|
|
153
|
+
evidenceRefs: [...new Set(input.evidenceRefs.map(sanitizeText))].sort(),
|
|
154
|
+
createdAt,
|
|
155
|
+
rawContentStored: false,
|
|
156
|
+
},
|
|
157
|
+
decision: null,
|
|
158
|
+
updatedAt: createdAt,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function writeOkfKnowledgeChangeCandidate(input: {
|
|
163
|
+
homeDir: string;
|
|
164
|
+
change: OkfKnowledgeChangeCandidateV1;
|
|
165
|
+
}): Promise<{ path: string; change: OkfKnowledgeChangeCandidateV1 }> {
|
|
166
|
+
validateOkfKnowledgeChangeCandidate(input.change);
|
|
167
|
+
const paths = resolveKnowledgeChangePaths(input.homeDir, input.change.projectKey);
|
|
168
|
+
const path = join(paths.candidatesDir, `${input.change.id}.json`);
|
|
169
|
+
await mkdir(paths.candidatesDir, { recursive: true, mode: 0o700 });
|
|
170
|
+
try {
|
|
171
|
+
const existing = await readOkfKnowledgeChangeCandidate({
|
|
172
|
+
homeDir: input.homeDir,
|
|
173
|
+
changeId: input.change.id,
|
|
174
|
+
projectKey: input.change.projectKey,
|
|
175
|
+
});
|
|
176
|
+
if (JSON.stringify(existing.change) !== JSON.stringify(input.change)) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Knowledge change id already exists with different content: ${input.change.id}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return existing;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (!isNotFoundError(error)) throw error;
|
|
184
|
+
}
|
|
185
|
+
await atomicWriteJson(path, input.change);
|
|
186
|
+
await appendOkfKnowledgeChangeHistory({
|
|
187
|
+
homeDir: input.homeDir,
|
|
188
|
+
change: input.change,
|
|
189
|
+
fromState: null,
|
|
190
|
+
reason: null,
|
|
191
|
+
});
|
|
192
|
+
return { path, change: input.change };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function listOkfKnowledgeChangeCandidates(input: {
|
|
196
|
+
homeDir: string;
|
|
197
|
+
projectKey?: string;
|
|
198
|
+
state?: OkfKnowledgeChangeState;
|
|
199
|
+
}): Promise<OkfKnowledgeChangeCandidateV1[]> {
|
|
200
|
+
const rootDir = join(
|
|
201
|
+
resolveEvoDevPaths(input.homeDir).stateDir,
|
|
202
|
+
"evolution",
|
|
203
|
+
"knowledge-changes",
|
|
204
|
+
);
|
|
205
|
+
const projectKeys =
|
|
206
|
+
input.projectKey === undefined
|
|
207
|
+
? await listDirectoryNames(rootDir)
|
|
208
|
+
: [sanitizePathSegment(input.projectKey, "project key")];
|
|
209
|
+
const changes: OkfKnowledgeChangeCandidateV1[] = [];
|
|
210
|
+
for (const projectKey of projectKeys) {
|
|
211
|
+
const paths = resolveKnowledgeChangePaths(input.homeDir, projectKey);
|
|
212
|
+
for (const name of await listJsonFileNames(paths.candidatesDir)) {
|
|
213
|
+
const value = JSON.parse(await readFile(join(paths.candidatesDir, name), "utf8")) as unknown;
|
|
214
|
+
const change = parseOkfKnowledgeChangeCandidate(value);
|
|
215
|
+
if (input.state === undefined || change.state === input.state) changes.push(change);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return changes.sort((left, right) => {
|
|
219
|
+
const updated = right.updatedAt.localeCompare(left.updatedAt);
|
|
220
|
+
return updated === 0 ? left.id.localeCompare(right.id) : updated;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function readOkfKnowledgeChangeCandidate(input: {
|
|
225
|
+
homeDir: string;
|
|
226
|
+
changeId: string;
|
|
227
|
+
projectKey?: string;
|
|
228
|
+
}): Promise<{ path: string; change: OkfKnowledgeChangeCandidateV1 }> {
|
|
229
|
+
const changeId = sanitizeChangeId(input.changeId);
|
|
230
|
+
if (input.projectKey !== undefined) {
|
|
231
|
+
const paths = resolveKnowledgeChangePaths(input.homeDir, input.projectKey);
|
|
232
|
+
const path = join(paths.candidatesDir, `${changeId}.json`);
|
|
233
|
+
const change = parseOkfKnowledgeChangeCandidate(
|
|
234
|
+
JSON.parse(await readFile(path, "utf8")) as unknown,
|
|
235
|
+
);
|
|
236
|
+
return { path, change };
|
|
237
|
+
}
|
|
238
|
+
const matches = await listOkfKnowledgeChangeCandidates({ homeDir: input.homeDir });
|
|
239
|
+
const filtered = matches.filter((change) => change.id === changeId);
|
|
240
|
+
if (filtered.length === 0) throw createNotFoundError(`Knowledge change not found: ${changeId}`);
|
|
241
|
+
if (filtered.length > 1) throw new Error(`Knowledge change id is ambiguous: ${changeId}`);
|
|
242
|
+
const change = filtered[0] as OkfKnowledgeChangeCandidateV1;
|
|
243
|
+
const paths = resolveKnowledgeChangePaths(input.homeDir, change.projectKey);
|
|
244
|
+
return { path: join(paths.candidatesDir, `${change.id}.json`), change };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function replaceOkfKnowledgeChangeCandidate(input: {
|
|
248
|
+
homeDir: string;
|
|
249
|
+
previous: OkfKnowledgeChangeCandidateV1;
|
|
250
|
+
next: OkfKnowledgeChangeCandidateV1;
|
|
251
|
+
reason: string | null;
|
|
252
|
+
}): Promise<{ path: string; change: OkfKnowledgeChangeCandidateV1 }> {
|
|
253
|
+
validateOkfKnowledgeChangeCandidate(input.previous);
|
|
254
|
+
validateOkfKnowledgeChangeCandidate(input.next);
|
|
255
|
+
if (input.previous.id !== input.next.id || input.previous.projectKey !== input.next.projectKey) {
|
|
256
|
+
throw new Error("Knowledge change identity cannot be replaced.");
|
|
257
|
+
}
|
|
258
|
+
const paths = resolveKnowledgeChangePaths(input.homeDir, input.next.projectKey);
|
|
259
|
+
const path = join(paths.candidatesDir, `${input.next.id}.json`);
|
|
260
|
+
const current = parseOkfKnowledgeChangeCandidate(
|
|
261
|
+
JSON.parse(await readFile(path, "utf8")) as unknown,
|
|
262
|
+
);
|
|
263
|
+
if (
|
|
264
|
+
current.state !== input.previous.state ||
|
|
265
|
+
current.updatedAt !== input.previous.updatedAt ||
|
|
266
|
+
current.candidate.revision !== input.previous.candidate.revision
|
|
267
|
+
) {
|
|
268
|
+
throw new Error("Knowledge change was modified by another decision.");
|
|
269
|
+
}
|
|
270
|
+
await atomicWriteJson(path, input.next);
|
|
271
|
+
await appendOkfKnowledgeChangeHistory({
|
|
272
|
+
homeDir: input.homeDir,
|
|
273
|
+
change: input.next,
|
|
274
|
+
fromState: input.previous.state,
|
|
275
|
+
reason: input.reason,
|
|
276
|
+
});
|
|
277
|
+
return { path, change: input.next };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function withOkfKnowledgeChangeLock<T>(input: {
|
|
281
|
+
homeDir: string;
|
|
282
|
+
projectKey: string;
|
|
283
|
+
stableKey: string;
|
|
284
|
+
conceptId: string | null;
|
|
285
|
+
run: () => Promise<T>;
|
|
286
|
+
}): Promise<T> {
|
|
287
|
+
const paths = resolveKnowledgeChangePaths(input.homeDir, input.projectKey);
|
|
288
|
+
await mkdir(paths.locksDir, { recursive: true, mode: 0o700 });
|
|
289
|
+
const lockKey = createHash("sha256")
|
|
290
|
+
.update(`${input.stableKey}\0${input.conceptId ?? "new"}`)
|
|
291
|
+
.digest("hex")
|
|
292
|
+
.slice(0, 32);
|
|
293
|
+
const lockPath = join(paths.locksDir, `${lockKey}.lock`);
|
|
294
|
+
const lockToken = randomUUID();
|
|
295
|
+
try {
|
|
296
|
+
await writeFile(
|
|
297
|
+
lockPath,
|
|
298
|
+
`${JSON.stringify({ pid: process.pid, token: lockToken, acquiredAt: new Date().toISOString() })}\n`,
|
|
299
|
+
{ encoding: "utf8", flag: "wx", mode: 0o600 },
|
|
300
|
+
);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (isAlreadyExistsError(error)) {
|
|
303
|
+
if (await recoverStaleKnowledgeChangeLock(lockPath)) {
|
|
304
|
+
return await withOkfKnowledgeChangeLock(input);
|
|
305
|
+
}
|
|
306
|
+
throw new OkfKnowledgeChangeLockError();
|
|
307
|
+
}
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
return await input.run();
|
|
312
|
+
} finally {
|
|
313
|
+
if ((await readKnowledgeChangeLockToken(lockPath)) === lockToken) {
|
|
314
|
+
await rm(lockPath, { force: true });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function parseOkfKnowledgeChangeCandidate(value: unknown): OkfKnowledgeChangeCandidateV1 {
|
|
320
|
+
validateOkfKnowledgeChangeCandidate(value);
|
|
321
|
+
return value;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function validateOkfKnowledgeChangeCandidate(
|
|
325
|
+
value: unknown,
|
|
326
|
+
): asserts value is OkfKnowledgeChangeCandidateV1 {
|
|
327
|
+
if (!isRecord(value)) throw new Error("Invalid knowledge change candidate.");
|
|
328
|
+
if (value.schemaVersion !== 1 || value.kind !== "okf-knowledge-change-candidate") {
|
|
329
|
+
throw new Error("Invalid knowledge change candidate contract.");
|
|
330
|
+
}
|
|
331
|
+
for (const field of ["id", "projectKey", "runId", "stableKey", "targetPath", "updatedAt"]) {
|
|
332
|
+
if (typeof value[field] !== "string" || value[field].trim() === "") {
|
|
333
|
+
throw new Error(`Invalid knowledge change ${field}.`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!["create", "update", "supersede", "revoke"].includes(String(value.operation))) {
|
|
337
|
+
throw new Error("Invalid knowledge change operation.");
|
|
338
|
+
}
|
|
339
|
+
if (
|
|
340
|
+
!["pending", "accepted", "rejected", "deferred", "applied", "superseded"].includes(
|
|
341
|
+
String(value.state),
|
|
342
|
+
)
|
|
343
|
+
) {
|
|
344
|
+
throw new Error("Invalid knowledge change state.");
|
|
345
|
+
}
|
|
346
|
+
if (!isRecord(value.candidate) || typeof value.candidate.revision !== "string") {
|
|
347
|
+
throw new Error("Invalid knowledge change candidate revision.");
|
|
348
|
+
}
|
|
349
|
+
if (!isRecord(value.diff) || !Array.isArray(value.diff.runtimeFields)) {
|
|
350
|
+
throw new Error("Invalid knowledge change diff.");
|
|
351
|
+
}
|
|
352
|
+
if (!isRecord(value.provenance) || value.provenance.rawContentStored !== false) {
|
|
353
|
+
throw new Error("Knowledge change provenance must remain metadata-only.");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function appendOkfKnowledgeChangeHistory(input: {
|
|
358
|
+
homeDir: string;
|
|
359
|
+
change: OkfKnowledgeChangeCandidateV1;
|
|
360
|
+
fromState: OkfKnowledgeChangeState | null;
|
|
361
|
+
reason: string | null;
|
|
362
|
+
}): Promise<void> {
|
|
363
|
+
const paths = resolveKnowledgeChangePaths(input.homeDir, input.change.projectKey);
|
|
364
|
+
await mkdir(dirname(paths.historyPath), { recursive: true, mode: 0o700 });
|
|
365
|
+
const entry: OkfKnowledgeChangeHistoryEntryV1 = {
|
|
366
|
+
schemaVersion: 1,
|
|
367
|
+
kind: "okf-knowledge-change-history",
|
|
368
|
+
changeId: input.change.id,
|
|
369
|
+
projectKey: input.change.projectKey,
|
|
370
|
+
operation: input.change.operation,
|
|
371
|
+
fromState: input.fromState,
|
|
372
|
+
toState: input.change.state,
|
|
373
|
+
baseRevision: input.change.base?.revision ?? null,
|
|
374
|
+
candidateRevision: input.change.candidate.revision,
|
|
375
|
+
runtimeFields: input.change.diff.runtimeFields,
|
|
376
|
+
reason: input.reason === null ? null : sanitizeText(input.reason),
|
|
377
|
+
changedAt: input.change.updatedAt,
|
|
378
|
+
rawContentStored: false,
|
|
379
|
+
};
|
|
380
|
+
await appendFile(paths.historyPath, `${JSON.stringify(entry)}\n`, {
|
|
381
|
+
encoding: "utf8",
|
|
382
|
+
mode: 0o600,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function resolveKnowledgeChangePaths(homeDir: string, projectKey: string): KnowledgeChangePaths {
|
|
387
|
+
const safeProjectKey = sanitizePathSegment(projectKey, "project key");
|
|
388
|
+
const evolutionStateDir = join(resolveEvoDevPaths(homeDir).stateDir, "evolution");
|
|
389
|
+
const rootDir = join(evolutionStateDir, "knowledge-changes");
|
|
390
|
+
const projectDir = join(rootDir, safeProjectKey);
|
|
391
|
+
return {
|
|
392
|
+
rootDir,
|
|
393
|
+
projectDir,
|
|
394
|
+
candidatesDir: join(projectDir, "candidates"),
|
|
395
|
+
historyPath: join(projectDir, "history.jsonl"),
|
|
396
|
+
locksDir: join(evolutionStateDir, ".knowledge-review-locks"),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function readKnowledgeChangeLockToken(path: string): Promise<string | null> {
|
|
401
|
+
try {
|
|
402
|
+
const value = JSON.parse(await readFile(path, "utf8")) as unknown;
|
|
403
|
+
return isRecord(value) && typeof value.token === "string" ? value.token : null;
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function recoverStaleKnowledgeChangeLock(path: string): Promise<boolean> {
|
|
410
|
+
const [metadata, owner] = await Promise.all([
|
|
411
|
+
stat(path).catch(() => null),
|
|
412
|
+
readKnowledgeChangeLockOwner(path),
|
|
413
|
+
]);
|
|
414
|
+
if (metadata === null) return true;
|
|
415
|
+
const expired = Date.now() - metadata.mtimeMs > KNOWLEDGE_CHANGE_LOCK_STALE_MS;
|
|
416
|
+
const ownerGone = owner?.pid !== null && owner?.pid !== undefined && !isProcessAlive(owner.pid);
|
|
417
|
+
const unknownOwnerExpired = owner?.pid === null || owner === null ? expired : false;
|
|
418
|
+
if (!ownerGone && !unknownOwnerExpired) return false;
|
|
419
|
+
const quarantinePath = `${path}.stale.${randomUUID()}`;
|
|
420
|
+
try {
|
|
421
|
+
await rename(path, quarantinePath);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (isNotFoundError(error)) return true;
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
await rm(quarantinePath, { force: true });
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function readKnowledgeChangeLockOwner(
|
|
431
|
+
path: string,
|
|
432
|
+
): Promise<{ pid: number | null; token: string | null } | null> {
|
|
433
|
+
try {
|
|
434
|
+
const value = JSON.parse(await readFile(path, "utf8")) as unknown;
|
|
435
|
+
if (!isRecord(value)) return null;
|
|
436
|
+
return {
|
|
437
|
+
pid:
|
|
438
|
+
typeof value.pid === "number" && Number.isSafeInteger(value.pid) && value.pid > 0
|
|
439
|
+
? value.pid
|
|
440
|
+
: null,
|
|
441
|
+
token: typeof value.token === "string" ? value.token : null,
|
|
442
|
+
};
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function isProcessAlive(pid: number): boolean {
|
|
449
|
+
try {
|
|
450
|
+
process.kill(pid, 0);
|
|
451
|
+
return true;
|
|
452
|
+
} catch (error) {
|
|
453
|
+
return isRecord(error) && error.code === "EPERM";
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function atomicWriteJson(path: string, value: unknown): Promise<void> {
|
|
458
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
459
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
460
|
+
try {
|
|
461
|
+
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
462
|
+
encoding: "utf8",
|
|
463
|
+
mode: 0o600,
|
|
464
|
+
});
|
|
465
|
+
await rename(temporaryPath, path);
|
|
466
|
+
} finally {
|
|
467
|
+
await rm(temporaryPath, { force: true });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function listDirectoryNames(path: string): Promise<string[]> {
|
|
472
|
+
try {
|
|
473
|
+
return (await readdir(path, { withFileTypes: true }))
|
|
474
|
+
.filter((entry) => entry.isDirectory())
|
|
475
|
+
.map((entry) => entry.name)
|
|
476
|
+
.sort();
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (isNotFoundError(error)) return [];
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function listJsonFileNames(path: string): Promise<string[]> {
|
|
484
|
+
try {
|
|
485
|
+
return (await readdir(path, { withFileTypes: true }))
|
|
486
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
487
|
+
.map((entry) => entry.name)
|
|
488
|
+
.sort();
|
|
489
|
+
} catch (error) {
|
|
490
|
+
if (isNotFoundError(error)) return [];
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function sanitizePathSegment(value: string, label: string): string {
|
|
496
|
+
const normalized = value.trim();
|
|
497
|
+
if (!/^[a-zA-Z0-9._-]+$/u.test(normalized) || normalized === "." || normalized === "..") {
|
|
498
|
+
throw new Error(`Invalid knowledge change ${label}.`);
|
|
499
|
+
}
|
|
500
|
+
return normalized;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function sanitizeChangeId(value: string): string {
|
|
504
|
+
const normalized = sanitizePathSegment(value, "id");
|
|
505
|
+
if (!normalized.startsWith("knowledge-change-")) {
|
|
506
|
+
throw new Error("Invalid knowledge change id.");
|
|
507
|
+
}
|
|
508
|
+
return normalized;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function sanitizeTargetPath(value: string): string {
|
|
512
|
+
const normalized = value.trim().replace(/\\/gu, "/").replace(/^\/+/u, "");
|
|
513
|
+
if (
|
|
514
|
+
normalized === "" ||
|
|
515
|
+
normalized.startsWith("../") ||
|
|
516
|
+
normalized.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
|
|
517
|
+
) {
|
|
518
|
+
throw new Error("Invalid knowledge change target path.");
|
|
519
|
+
}
|
|
520
|
+
return normalized;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function sanitizeText(value: string): string {
|
|
524
|
+
return [...value]
|
|
525
|
+
.filter((character) => {
|
|
526
|
+
const code = character.charCodeAt(0);
|
|
527
|
+
return code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127);
|
|
528
|
+
})
|
|
529
|
+
.join("")
|
|
530
|
+
.trim();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function normalizeTimestamp(value: string): string {
|
|
534
|
+
const time = Date.parse(value);
|
|
535
|
+
if (!Number.isFinite(time)) throw new Error("Invalid knowledge change timestamp.");
|
|
536
|
+
return new Date(time).toISOString();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function createNotFoundError(message: string): Error & { code: "ENOENT" } {
|
|
540
|
+
return Object.assign(new Error(message), { code: "ENOENT" as const });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
544
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function isNotFoundError(error: unknown): boolean {
|
|
548
|
+
return (
|
|
549
|
+
isRecord(error) &&
|
|
550
|
+
(error.code === "ENOENT" ||
|
|
551
|
+
(typeof error.message === "string" &&
|
|
552
|
+
error.message.startsWith("Knowledge change not found:")))
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function isAlreadyExistsError(error: unknown): boolean {
|
|
557
|
+
return isRecord(error) && error.code === "EEXIST";
|
|
558
|
+
}
|