agent-lyceum 0.22.0 → 0.24.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/adapters/claude.d.ts +6 -1
- package/dist/adapters/claude.js +13 -9
- package/dist/adapters/codex.js +6 -4
- package/dist/adapters/types.d.ts +6 -0
- package/dist/call.js +0 -1
- package/dist/cli.js +7 -3
- package/dist/doctor.js +12 -0
- package/dist/fs-util.d.ts +23 -0
- package/dist/fs-util.js +78 -0
- package/dist/guard.d.ts +8 -0
- package/dist/guard.js +123 -1
- package/dist/mailbox.js +6 -0
- package/dist/memory-tidy.d.ts +14 -1
- package/dist/memory-tidy.js +184 -48
- package/dist/message-store.js +22 -1
- package/dist/paths.d.ts +0 -1
- package/dist/paths.js +0 -1
- package/dist/policy.d.ts +5 -5
- package/dist/policy.js +12 -12
- package/dist/prompt.d.ts +28 -4
- package/dist/prompt.js +71 -43
- package/dist/records.d.ts +30 -0
- package/dist/records.js +130 -0
- package/dist/redo.d.ts +57 -0
- package/dist/redo.js +281 -0
- package/dist/run-session.d.ts +5 -0
- package/dist/run-session.js +50 -8
- package/dist/scaffold.js +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import { type WritePolicy } from "../policy.js";
|
|
1
2
|
import type { ResolvedAgent, ResolvedProject } from "../config.js";
|
|
2
3
|
import type { ErrorKind } from "../schema.js";
|
|
3
4
|
import type { Invocation, WakeInput } from "./types.js";
|
|
4
|
-
|
|
5
|
+
/** `override`: a policy that replaces the agent's own; `cwd` is then the (scratch) directory the runtime starts in, in place of the repo. */
|
|
6
|
+
export declare function buildClaudeSettings(project: ResolvedProject, agent: ResolvedAgent, override?: {
|
|
7
|
+
policy: WritePolicy;
|
|
8
|
+
cwd: string;
|
|
9
|
+
}): Record<string, unknown>;
|
|
5
10
|
export declare function buildClaudeInvocation(input: WakeInput): Invocation;
|
|
6
11
|
/**
|
|
7
12
|
* The text the rules read: the result message (only when it is an error message: pass `result` for an `is_error` run), stderr, and the HTTP status
|
package/dist/adapters/claude.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { repoDirFor, writePolicy } from "../policy.js";
|
|
3
|
+
import { repoDirFor, scratchCwd, writePolicy } from "../policy.js";
|
|
4
4
|
/** Claude Code path rules: `//abs/path` — a leading "/" plus the absolute path. */
|
|
5
5
|
const rule = (tool, p) => `${tool}(/${p})`;
|
|
6
6
|
function denyRules(p) {
|
|
@@ -17,10 +17,11 @@ function denyRules(p) {
|
|
|
17
17
|
out.push(rule("Edit", `${p}/**`));
|
|
18
18
|
return out;
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
20
|
+
/** `override`: a policy that replaces the agent's own; `cwd` is then the (scratch) directory the runtime starts in, in place of the repo. */
|
|
21
|
+
export function buildClaudeSettings(project, agent, override) {
|
|
22
|
+
const pol = override?.policy ?? writePolicy(project, agent);
|
|
23
|
+
const ws = override ? undefined : project.workspaces?.[agent.name];
|
|
24
|
+
const repo = override?.cwd ?? repoDirFor(project, agent.name);
|
|
24
25
|
const allow = ["Bash", "Read", "Glob", "Grep"];
|
|
25
26
|
// Web tools are opt-in: `search` is the lighter one (snippets only), `fetch` also pulls whole pages into the agent's context.
|
|
26
27
|
if (agent.allowWeb === "search" || agent.allowWeb === "fetch")
|
|
@@ -41,7 +42,7 @@ export function buildClaudeSettings(project, agent) {
|
|
|
41
42
|
enabled: true,
|
|
42
43
|
allowUnsandboxedCommands: false,
|
|
43
44
|
filesystem: {
|
|
44
|
-
allowWrite: [ws?.root ?? project.dir, ...pol.allowDirs, ...pol.allowFiles],
|
|
45
|
+
allowWrite: [override?.cwd ?? ws?.root ?? project.dir, ...pol.allowDirs, ...pol.allowFiles],
|
|
45
46
|
denyWrite: pol.deny,
|
|
46
47
|
},
|
|
47
48
|
},
|
|
@@ -52,10 +53,13 @@ export function buildClaudeInvocation(input) {
|
|
|
52
53
|
fs.mkdirSync(workDir, { recursive: true });
|
|
53
54
|
const settingsFile = path.join(workDir, "claude-settings.json");
|
|
54
55
|
const systemFile = path.join(workDir, "system-prompt.md");
|
|
55
|
-
|
|
56
|
+
const repo = input.policy ? scratchCwd(workDir) : repoDirFor(project, agent.name);
|
|
57
|
+
const scope = input.policy ? { policy: input.policy, cwd: repo } : undefined;
|
|
58
|
+
if (scope)
|
|
59
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
60
|
+
fs.writeFileSync(settingsFile, JSON.stringify(buildClaudeSettings(project, agent, scope), null, 2));
|
|
56
61
|
fs.writeFileSync(systemFile, input.systemPrompt);
|
|
57
|
-
const pol = writePolicy(project, agent);
|
|
58
|
-
const repo = repoDirFor(project, agent.name);
|
|
62
|
+
const pol = input.policy ?? writePolicy(project, agent);
|
|
59
63
|
const addDirs = [...pol.allowDirs, ...pol.readDirs, ...pol.allowFiles.map((f) => path.dirname(f))].filter((d) => !(d === repo || d.startsWith(repo + path.sep)));
|
|
60
64
|
const args = [
|
|
61
65
|
"-p",
|
package/dist/adapters/codex.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { repoDirFor, writePolicy } from "../policy.js";
|
|
3
|
+
import { repoDirFor, scratchCwd, writePolicy } from "../policy.js";
|
|
4
4
|
export function codexWritableRoots(input) {
|
|
5
|
-
const pol = writePolicy(input.project, input.agent);
|
|
6
|
-
const work = repoDirFor(input.project, input.agent.name);
|
|
5
|
+
const pol = input.policy ?? writePolicy(input.project, input.agent);
|
|
6
|
+
const work = input.policy ? scratchCwd(input.workDir) : repoDirFor(input.project, input.agent.name);
|
|
7
7
|
const roots = [...pol.allowDirs, ...pol.allowFiles.map((f) => path.dirname(f))].filter((d) => !(d === work || d.startsWith(work + path.sep)));
|
|
8
8
|
return [...new Set(roots)];
|
|
9
9
|
}
|
|
@@ -52,8 +52,10 @@ export function codexOutputTokens(jsonl) {
|
|
|
52
52
|
const CODEX_WEB_SEARCH = { none: "disabled", search: "cached", fetch: "live" };
|
|
53
53
|
export function buildCodexInvocation(input) {
|
|
54
54
|
const { project, agent, workDir } = input;
|
|
55
|
-
const repo = repoDirFor(project, agent.name);
|
|
55
|
+
const repo = input.policy ? scratchCwd(workDir) : repoDirFor(project, agent.name);
|
|
56
56
|
fs.mkdirSync(workDir, { recursive: true });
|
|
57
|
+
if (input.policy)
|
|
58
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
57
59
|
const lastFile = path.join(workDir, "codex-last-message.txt");
|
|
58
60
|
const roots = codexWritableRoots(input);
|
|
59
61
|
const common = [
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ErrorKind } from "../schema.js";
|
|
2
2
|
import type { ResolvedAgent, ResolvedProject } from "../config.js";
|
|
3
|
+
import type { WritePolicy } from "../policy.js";
|
|
3
4
|
import type { AgentWorkspace } from "../worktree.js";
|
|
4
5
|
export interface WakeInput {
|
|
5
6
|
project: ResolvedProject;
|
|
@@ -16,6 +17,11 @@ export interface WakeInput {
|
|
|
16
17
|
signal?: AbortSignal;
|
|
17
18
|
/** Where the full stdout/stderr of this invocation are written. */
|
|
18
19
|
logDir?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Replaces the agent's normal write policy (memory tidy: only the dirs being tidied). The repo is then not writable
|
|
22
|
+
* either: the runtime starts in an empty scratch dir inside `workDir` and may only read the repo.
|
|
23
|
+
*/
|
|
24
|
+
policy?: WritePolicy;
|
|
19
25
|
}
|
|
20
26
|
export interface WakeResult {
|
|
21
27
|
ok: boolean;
|
package/dist/call.js
CHANGED
|
@@ -43,7 +43,6 @@ export function callProject(home, agent, dir, callDir) {
|
|
|
43
43
|
config: path.join(callDir, "call.yaml"),
|
|
44
44
|
agentsDir: path.join(callDir, "agents"),
|
|
45
45
|
shared: path.join(callDir, "shared"),
|
|
46
|
-
common: path.join(callDir, "shared", "COMMON.md"),
|
|
47
46
|
inboxRoot: path.join(callDir, "mail", "inbox"),
|
|
48
47
|
outboxRoot: path.join(callDir, "mail", "outbox"),
|
|
49
48
|
runs: path.join(callDir, "runs"),
|
package/dist/cli.js
CHANGED
|
@@ -693,7 +693,11 @@ program
|
|
|
693
693
|
console.log(` restored ${f}`);
|
|
694
694
|
for (const s of r.skipped)
|
|
695
695
|
console.log(` skipped ${s.rel}: ${s.reason}`);
|
|
696
|
-
|
|
696
|
+
for (const f of r.records?.restored ?? [])
|
|
697
|
+
console.log(` restored records/${f}`);
|
|
698
|
+
for (const k of r.records?.skipped ?? [])
|
|
699
|
+
console.log(` skipped records/${k.rel}: ${k.reason}`);
|
|
700
|
+
if (!r.restored.length && !r.skipped.length && !r.records?.restored.length && !r.records?.skipped.length)
|
|
697
701
|
console.log(`Archive ${opts.undo} holds no files to restore.`);
|
|
698
702
|
}
|
|
699
703
|
finally {
|
|
@@ -728,11 +732,11 @@ program
|
|
|
728
732
|
console.log(`No agent has ${layer} memory configured. ${layer === "project" ? "The template gives agents global memory only: set `memory.project` for an agent in project.yaml (or tidy global memory with --layer global)." : "Set `memory.global` for an agent."}`);
|
|
729
733
|
for (const r of results) {
|
|
730
734
|
if (r.status === "tidied")
|
|
731
|
-
console.log(` ${r.agent} (${r.layer}): tidied. Archive: ${path.join(r.dir, ".archive", r.archive ?? "")} — undo with: agent-lyceum tidy --undo ${r.archive} -p ${pr.name} --agent ${r.agent}${r.layer === "global" ? " --layer global" : ""}`);
|
|
735
|
+
console.log(` ${r.agent} (${r.layer}): tidied. Archive: ${path.join(r.dir, ".archive", r.archive ?? "")}${r.recordsDir && r.recordsDir !== r.dir ? `, records: ${path.join(r.recordsDir, ".archive", r.archive ?? "")}` : ""} — undo with: agent-lyceum tidy --undo ${r.archive} -p ${pr.name} --agent ${r.agent}${r.layer === "global" ? " --layer global" : ""}`);
|
|
732
736
|
else if (r.status === "skipped")
|
|
733
737
|
console.log(` ${r.agent} (${r.layer}): skipped — ${r.problems.join("; ")}`);
|
|
734
738
|
else if (r.status === "failed") {
|
|
735
|
-
console.error(` ${r.agent} (${r.layer}): FAILED, memory restored to how it was`);
|
|
739
|
+
console.error(` ${r.agent} (${r.layer}): FAILED, ${r.recordsDir === r.dir ? "project records" : r.recordsDir ? "memory and project records" : "memory"} restored to how it was`);
|
|
736
740
|
for (const p of r.problems)
|
|
737
741
|
console.error(` - ${p}`);
|
|
738
742
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -3,6 +3,7 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { findOnPath } from "./fs-util.js";
|
|
5
5
|
import { tidyAdvice } from "./memory-tidy.js";
|
|
6
|
+
import { listRecords, RECORD_KINDS } from "./records.js";
|
|
6
7
|
import { listRunLocks, listUnreadableRunLocks, lockHolderAlive } from "./project-lock.js";
|
|
7
8
|
import { runInvocation } from "./process-runner.js";
|
|
8
9
|
import { validateProject } from "./validate.js";
|
|
@@ -169,6 +170,17 @@ export async function diagnoseProject(project, opts = {}) {
|
|
|
169
170
|
}
|
|
170
171
|
for (const a of tidyAdvice(project))
|
|
171
172
|
checks.push({ level: "info", subject: "memory", message: `${a.agent}: ${a.reason}. Consider: agent-lyceum tidy -p ${project.name} --agent ${a.agent} (doctor only suggests; it never tidies).` });
|
|
173
|
+
for (const kind of RECORD_KINDS) {
|
|
174
|
+
const recs = listRecords(project, kind);
|
|
175
|
+
const ids = new Set(recs.map((r) => r.id));
|
|
176
|
+
for (const r of recs) {
|
|
177
|
+
if (r.invalid)
|
|
178
|
+
checks.push({ level: "warn", subject: "records", message: `${r.file}: ${r.invalid}` });
|
|
179
|
+
for (const s of r.supersedes)
|
|
180
|
+
if (!ids.has(s))
|
|
181
|
+
checks.push({ level: "warn", subject: "records", message: `${r.file}: supersedes "${s}", but no ${kind} entry has that id.` });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
172
184
|
const runtimes = await probeProjectRuntimes(project, opts, true);
|
|
173
185
|
checks.push(...judgeRuntimes(project, runtimes));
|
|
174
186
|
checks.push({ level: "info", subject: "login", message: "Login status is unknown: agent-lyceum does not read credentials, and the CLIs offer no read-only login check it can rely on." });
|
package/dist/fs-util.d.ts
CHANGED
|
@@ -10,3 +10,26 @@ export declare function readFileCapped(file: string, limit: number): {
|
|
|
10
10
|
text: string;
|
|
11
11
|
truncated: boolean;
|
|
12
12
|
} | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* The bytes of a regular file, opened without following a symlink and without blocking (a FIFO opens at once and fails the
|
|
15
|
+
* fstat check). undefined for anything else or on any error: never throws, never hangs.
|
|
16
|
+
*/
|
|
17
|
+
export declare function readRegularFileNoFollow(file: string): Buffer | undefined;
|
|
18
|
+
/** `buf` cut to at most `limit` bytes, backed up to a character boundary so a multi-byte character is never split. */
|
|
19
|
+
export declare function cutUtf8(buf: Buffer, limit: number): Buffer;
|
|
20
|
+
/** `buf` without a trailing incomplete UTF-8 sequence (a cut-off lead byte and its continuation bytes). */
|
|
21
|
+
export declare function trimPartialUtf8(buf: Buffer): Buffer;
|
|
22
|
+
export type CappedRead = {
|
|
23
|
+
ok: true;
|
|
24
|
+
buf: Buffer;
|
|
25
|
+
size: number;
|
|
26
|
+
truncated: boolean;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
reason: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Up to `limit` bytes of a regular file, opened without following a symlink and without blocking; reads at most `limit + 1` bytes.
|
|
33
|
+
* `size` is the file's size from fstat. Never throws: a refusal says why, from the one open (`symlink`, `not_regular`, `open_error:<code>`).
|
|
34
|
+
*/
|
|
35
|
+
export declare function readRegularFileCappedNoFollow(file: string, limit: number): CappedRead;
|
package/dist/fs-util.js
CHANGED
|
@@ -58,3 +58,81 @@ export function readFileCapped(file, limit) {
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* The bytes of a regular file, opened without following a symlink and without blocking (a FIFO opens at once and fails the
|
|
63
|
+
* fstat check). undefined for anything else or on any error: never throws, never hangs.
|
|
64
|
+
*/
|
|
65
|
+
export function readRegularFileNoFollow(file) {
|
|
66
|
+
let fd;
|
|
67
|
+
try {
|
|
68
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
|
|
69
|
+
if (!fs.fstatSync(fd).isFile())
|
|
70
|
+
return undefined;
|
|
71
|
+
return fs.readFileSync(fd);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
if (fd !== undefined) {
|
|
78
|
+
try {
|
|
79
|
+
fs.closeSync(fd);
|
|
80
|
+
}
|
|
81
|
+
catch { }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** `buf` cut to at most `limit` bytes, backed up to a character boundary so a multi-byte character is never split. */
|
|
86
|
+
export function cutUtf8(buf, limit) {
|
|
87
|
+
if (buf.length <= limit)
|
|
88
|
+
return buf;
|
|
89
|
+
let cut = limit;
|
|
90
|
+
while (cut > 0 && ((buf[cut] ?? 0) & 0xc0) === 0x80)
|
|
91
|
+
cut--;
|
|
92
|
+
return buf.subarray(0, cut);
|
|
93
|
+
}
|
|
94
|
+
/** `buf` without a trailing incomplete UTF-8 sequence (a cut-off lead byte and its continuation bytes). */
|
|
95
|
+
export function trimPartialUtf8(buf) {
|
|
96
|
+
for (let i = 1; i <= Math.min(3, buf.length); i++) {
|
|
97
|
+
const b = buf[buf.length - i];
|
|
98
|
+
if ((b & 0xc0) === 0x80)
|
|
99
|
+
continue;
|
|
100
|
+
const need = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1;
|
|
101
|
+
return need > i ? buf.subarray(0, buf.length - i) : buf;
|
|
102
|
+
}
|
|
103
|
+
return buf;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Up to `limit` bytes of a regular file, opened without following a symlink and without blocking; reads at most `limit + 1` bytes.
|
|
107
|
+
* `size` is the file's size from fstat. Never throws: a refusal says why, from the one open (`symlink`, `not_regular`, `open_error:<code>`).
|
|
108
|
+
*/
|
|
109
|
+
export function readRegularFileCappedNoFollow(file, limit) {
|
|
110
|
+
let fd;
|
|
111
|
+
try {
|
|
112
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
|
|
113
|
+
const st = fs.fstatSync(fd);
|
|
114
|
+
if (!st.isFile())
|
|
115
|
+
return { ok: false, reason: "not_regular" };
|
|
116
|
+
const buf = Buffer.alloc(limit + 1);
|
|
117
|
+
let n = 0;
|
|
118
|
+
while (n < buf.length) {
|
|
119
|
+
const r = fs.readSync(fd, buf, n, buf.length - n, n);
|
|
120
|
+
if (r === 0)
|
|
121
|
+
break;
|
|
122
|
+
n += r;
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, buf: buf.subarray(0, Math.min(n, limit)), size: st.size, truncated: n > limit };
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
const code = e.code ?? "unknown";
|
|
128
|
+
return { ok: false, reason: code === "ELOOP" ? "symlink" : `open_error:${code}` };
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
if (fd !== undefined) {
|
|
132
|
+
try {
|
|
133
|
+
fs.closeSync(fd);
|
|
134
|
+
}
|
|
135
|
+
catch { }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
package/dist/guard.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export declare const MAX_KEEP_BYTES: number;
|
|
|
17
17
|
export declare class ProtectedGuard {
|
|
18
18
|
private project;
|
|
19
19
|
private baseline;
|
|
20
|
+
/** Regular files of the records tree (including `.archive/`); only the lead may change them. */
|
|
21
|
+
private records;
|
|
20
22
|
constructor(project: ResolvedProject);
|
|
21
23
|
/**
|
|
22
24
|
* Take the current files as the baseline. Called right before a batch is woken, so only what changes while it runs is
|
|
@@ -30,4 +32,10 @@ export declare class ProtectedGuard {
|
|
|
30
32
|
private keep;
|
|
31
33
|
/** Compare against the baseline after `running` agents finished; revert unauthorized changes. */
|
|
32
34
|
check(running: ResolvedAgent[]): Violation[];
|
|
35
|
+
private checkRecords;
|
|
36
|
+
/**
|
|
37
|
+
* Make every directory from the records root down to `dir` a real directory. A symlink or file standing in for one is an
|
|
38
|
+
* unauthorized change: unlink it (never follow it), recreate the directory and report it. False when `dir` is not safe to write into.
|
|
39
|
+
*/
|
|
40
|
+
private realDirs;
|
|
33
41
|
}
|
package/dist/guard.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { readRegularFileNoFollow } from "./fs-util.js";
|
|
5
|
+
import { mayEditProtected, recordsDir, repoInstructionFiles, sharedContextFiles, sharedIndex } from "./policy.js";
|
|
5
6
|
export const MAX_KEEP_BYTES = 1024 * 1024;
|
|
6
7
|
function safeName(file) {
|
|
7
8
|
return file.replace(/[\\/:]/g, "_").replace(/^_+/, "");
|
|
@@ -13,6 +14,8 @@ function safeName(file) {
|
|
|
13
14
|
export class ProtectedGuard {
|
|
14
15
|
project;
|
|
15
16
|
baseline = new Map();
|
|
17
|
+
/** Regular files of the records tree (including `.archive/`); only the lead may change them. */
|
|
18
|
+
records = new Map();
|
|
16
19
|
constructor(project) {
|
|
17
20
|
this.project = project;
|
|
18
21
|
this.rebase();
|
|
@@ -24,6 +27,7 @@ export class ProtectedGuard {
|
|
|
24
27
|
rebase() {
|
|
25
28
|
for (const f of ProtectedGuard.protectedFiles(this.project))
|
|
26
29
|
this.baseline.set(f, read(f));
|
|
30
|
+
this.records = readTree(recordsDir(this.project));
|
|
27
31
|
}
|
|
28
32
|
static protectedFiles(project) {
|
|
29
33
|
const files = new Set();
|
|
@@ -86,8 +90,126 @@ export class ProtectedGuard {
|
|
|
86
90
|
violations.push({ file, action: "restored", suspects: running.map((a) => a.name), ...kept });
|
|
87
91
|
}
|
|
88
92
|
}
|
|
93
|
+
this.checkRecords(running, violations);
|
|
89
94
|
return violations;
|
|
90
95
|
}
|
|
96
|
+
checkRecords(running, violations) {
|
|
97
|
+
const root = recordsDir(this.project);
|
|
98
|
+
// Never write through a symlinked parent of the records tree: skip its protection for this batch.
|
|
99
|
+
if (isLink(path.dirname(root)) || isLink(path.dirname(path.dirname(root))))
|
|
100
|
+
return;
|
|
101
|
+
const now = readTree(root);
|
|
102
|
+
const lead = running.some((a) => a.name === this.project.lead);
|
|
103
|
+
const suspects = running.map((a) => a.name);
|
|
104
|
+
const fixedDirs = new Set();
|
|
105
|
+
for (const file of new Set([...this.records.keys(), ...now.keys()])) {
|
|
106
|
+
const before = this.records.get(file) ?? null;
|
|
107
|
+
const cur = now.get(file) ?? null;
|
|
108
|
+
if (same(before, cur))
|
|
109
|
+
continue;
|
|
110
|
+
if (lead)
|
|
111
|
+
continue;
|
|
112
|
+
const kept = this.keep(file, cur);
|
|
113
|
+
if (before === null) {
|
|
114
|
+
if (!insideRoot(root, path.dirname(file)))
|
|
115
|
+
continue;
|
|
116
|
+
fs.rmSync(file, { force: true });
|
|
117
|
+
violations.push({ file, action: "removed", suspects, ...kept });
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
if (!this.realDirs(root, path.dirname(file), fixedDirs, suspects, violations))
|
|
121
|
+
continue;
|
|
122
|
+
fs.rmSync(file, { recursive: true, force: true });
|
|
123
|
+
fs.writeFileSync(file, before);
|
|
124
|
+
violations.push({ file, action: "restored", suspects, ...kept });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (lead)
|
|
128
|
+
this.records = now;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Make every directory from the records root down to `dir` a real directory. A symlink or file standing in for one is an
|
|
132
|
+
* unauthorized change: unlink it (never follow it), recreate the directory and report it. False when `dir` is not safe to write into.
|
|
133
|
+
*/
|
|
134
|
+
realDirs(root, dir, done, suspects, violations) {
|
|
135
|
+
const rel = path.relative(root, dir);
|
|
136
|
+
if (rel.startsWith("..") || path.isAbsolute(rel))
|
|
137
|
+
return false;
|
|
138
|
+
let cur = root;
|
|
139
|
+
for (const part of ["", ...(rel ? rel.split(path.sep) : [])]) {
|
|
140
|
+
cur = part ? path.join(cur, part) : cur;
|
|
141
|
+
let st;
|
|
142
|
+
try {
|
|
143
|
+
st = fs.lstatSync(cur);
|
|
144
|
+
}
|
|
145
|
+
catch { }
|
|
146
|
+
if (st?.isDirectory())
|
|
147
|
+
continue;
|
|
148
|
+
if (st) {
|
|
149
|
+
fs.rmSync(cur, { force: true });
|
|
150
|
+
if (!done.has(cur))
|
|
151
|
+
violations.push({ file: cur, action: "removed", suspects, ...this.keep(cur, null) });
|
|
152
|
+
done.add(cur);
|
|
153
|
+
}
|
|
154
|
+
fs.mkdirSync(cur, { recursive: true });
|
|
155
|
+
}
|
|
156
|
+
return insideRoot(root, dir);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function isLink(p) {
|
|
160
|
+
try {
|
|
161
|
+
return fs.lstatSync(p).isSymbolicLink();
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function insideRoot(root, dir) {
|
|
168
|
+
try {
|
|
169
|
+
const r = path.relative(fs.realpathSync(root), fs.realpathSync(dir));
|
|
170
|
+
return !r.startsWith("..") && !path.isAbsolute(r);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Regular files under `dir`, recursively; symlinks and special files are not followed or read. */
|
|
177
|
+
function readTree(dir) {
|
|
178
|
+
const out = new Map();
|
|
179
|
+
try {
|
|
180
|
+
if (!fs.lstatSync(dir).isDirectory())
|
|
181
|
+
return out; // a symlinked root is never walked
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
const walk = (d) => {
|
|
187
|
+
let names;
|
|
188
|
+
try {
|
|
189
|
+
names = fs.readdirSync(d);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
for (const n of names) {
|
|
195
|
+
const f = path.join(d, n);
|
|
196
|
+
try {
|
|
197
|
+
const st = fs.lstatSync(f);
|
|
198
|
+
if (st.isDirectory())
|
|
199
|
+
walk(f);
|
|
200
|
+
else if (st.isFile()) {
|
|
201
|
+
const buf = readRegularFileNoFollow(f);
|
|
202
|
+
if (buf)
|
|
203
|
+
out.set(f, buf);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// vanished while scanning
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
walk(dir);
|
|
212
|
+
return out;
|
|
91
213
|
}
|
|
92
214
|
function read(file) {
|
|
93
215
|
try {
|
package/dist/mailbox.js
CHANGED
|
@@ -7,6 +7,7 @@ import { atomicWrite } from "./fs-util.js";
|
|
|
7
7
|
import { countFormatRejection, formatRejectionKey, formatRejections, MAX_FORMAT_REJECTIONS } from "./format-rejections.js";
|
|
8
8
|
import { deterministicId, fault, RouteJournal, sourceIdOf } from "./message-store.js";
|
|
9
9
|
import { inboxDir, outboxDir, sharedDir } from "./policy.js";
|
|
10
|
+
import { activeRecordsBytes, RECORD_KINDS, RECORDS_BUDGET } from "./records.js";
|
|
10
11
|
import { must } from "./assert.js";
|
|
11
12
|
export { atomicWrite };
|
|
12
13
|
export const MESSAGE_TYPES = ["task", "reply", "failure", "done", "ask"];
|
|
@@ -259,6 +260,11 @@ export function routeOutboxes(project) {
|
|
|
259
260
|
reject(`only the lead ("${project.lead}") may send a "done" message.`);
|
|
260
261
|
continue;
|
|
261
262
|
}
|
|
263
|
+
const over = RECORD_KINDS.map((k) => ({ k, n: activeRecordsBytes(project, k) })).filter((x) => x.n > RECORDS_BUDGET);
|
|
264
|
+
if (over.length) {
|
|
265
|
+
reject(`${over.map((x) => `records/${x.k} hold ${x.n} bytes of active entries`).join(" and ")}; the budget is ${RECORDS_BUDGET} bytes (6 KB) per kind. Merge entries, or mark obsolete ones "status: superseded" (or name them in a newer entry's "supersedes"), then send "done" again.`);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
262
268
|
res.done = { from: sender.name, subject, body: parsed.body, file, meta: d };
|
|
263
269
|
const ds = parseSteps(parsed.body);
|
|
264
270
|
if (ds.length)
|
package/dist/memory-tidy.d.ts
CHANGED
|
@@ -84,6 +84,8 @@ export interface TidyResult {
|
|
|
84
84
|
status: "tidied" | "skipped" | "failed" | "dry-run";
|
|
85
85
|
/** The archive stamp of a finished tidy. */
|
|
86
86
|
archive?: string;
|
|
87
|
+
/** Set when the project records were tidied too: their folder (its archive is `<recordsDir>/.archive/<archive>`). */
|
|
88
|
+
recordsDir?: string;
|
|
87
89
|
problems: string[];
|
|
88
90
|
}
|
|
89
91
|
/** Scratch space of one tidy (snapshot and the invocation's logs); outside every memory dir and every run. */
|
|
@@ -96,8 +98,19 @@ export interface RestoreResult {
|
|
|
96
98
|
rel: string;
|
|
97
99
|
reason: string;
|
|
98
100
|
}[];
|
|
101
|
+
/** The same for the project records archive of that stamp, when the lead's undo found one. */
|
|
102
|
+
records?: {
|
|
103
|
+
restored: string[];
|
|
104
|
+
skipped: {
|
|
105
|
+
rel: string;
|
|
106
|
+
reason: string;
|
|
107
|
+
}[];
|
|
108
|
+
};
|
|
99
109
|
}
|
|
100
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Move the files of one tidy's archive back to where they were. Nothing that exists now is overwritten.
|
|
112
|
+
* For the lead on the project layer this covers the project records archive of the same stamp too; either archive may be the only one.
|
|
113
|
+
*/
|
|
101
114
|
export declare function restoreMemory(o: {
|
|
102
115
|
project: ResolvedProject;
|
|
103
116
|
agent: string;
|