@letta-ai/letta-code 0.30.26 → 0.30.28
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/agent-presets.js +17 -17
- package/dist/agent-presets.js.map +1 -1
- package/dist/mcp-client.js +2 -2
- package/dist/mcp-client.js.map +1 -1
- package/dist/types/agent/turn-recovery-policy.d.ts +33 -0
- package/dist/types/agent/turn-recovery-policy.d.ts.map +1 -1
- package/dist/types/tools/impl/apply-patch.d.ts.map +1 -1
- package/dist/types/tools/secret-substitution.d.ts.map +1 -1
- package/dist/types/types/loop-status-protocol.d.ts +17 -0
- package/dist/types/types/loop-status-protocol.d.ts.map +1 -0
- package/dist/types/types/protocol_v2.d.ts +2 -19
- package/dist/types/types/protocol_v2.d.ts.map +1 -1
- package/dist/types/websocket/listener/inbound-queue.d.ts +5 -0
- package/dist/types/websocket/listener/inbound-queue.d.ts.map +1 -0
- package/dist/types/websocket/listener/protocol-outbound-routing.d.ts +9 -0
- package/dist/types/websocket/listener/protocol-outbound-routing.d.ts.map +1 -0
- package/dist/types/websocket/listener/protocol-outbound.d.ts.map +1 -1
- package/dist/types/websocket/listener/runtime.d.ts.map +1 -1
- package/dist/types/websocket/listener/turn-correlation.d.ts +10 -0
- package/dist/types/websocket/listener/turn-correlation.d.ts.map +1 -0
- package/dist/types/websocket/listener/types.d.ts +4 -0
- package/dist/types/websocket/listener/types.d.ts.map +1 -1
- package/letta.js +545 -120
- package/package.json +1 -1
- package/scripts/claude-watch/agent-watch.ts +622 -0
- package/scripts/claude-watch/docs-snapshot.test.ts +259 -0
- package/scripts/claude-watch/docs-snapshot.ts +672 -0
- package/scripts/claude-watch/fixtures/historical-replays.json +52 -0
- package/scripts/claude-watch/github.ts +137 -0
- package/scripts/claude-watch/release-analysis.test.ts +235 -0
- package/scripts/claude-watch/release-analysis.ts +297 -0
- package/scripts/claude-watch/release-source.test.ts +179 -0
- package/scripts/claude-watch/release-source.ts +369 -0
- package/scripts/claude-watch/runtime-observations.ts +98 -0
- package/scripts/claude-watch/runtime-probe.test.ts +576 -0
- package/scripts/claude-watch/runtime-probe.ts +911 -0
- package/scripts/claude-watch/runtime-sandbox.ts +170 -0
- package/scripts/claude-watch/state-branch.test.ts +211 -0
- package/scripts/claude-watch/state-branch.ts +316 -0
- package/scripts/claude-watch/tracker.test.ts +148 -0
- package/scripts/claude-watch/tracker.ts +325 -0
- package/scripts/claude-watch/types.ts +186 -0
- package/scripts/claude-watch/update-tracker.ts +201 -0
- package/scripts/codex-watch/agent-watch.ts +2 -2
- package/scripts/codex-watch/release-analysis.ts +14 -2
- package/scripts/codex-watch/tracker.ts +1 -3
- package/scripts/run-unit-tests.cjs +2 -0
- package/scripts/source-file-size-baseline.json +6 -5
- package/skills/creating-mods/references/commands.md +1 -1
- package/skills/creating-mods/references/ui.md +1 -1
- package/skills/customizing-commands/SKILL.md +1 -1
- package/skills/initializing-memory/SKILL.md +7 -7
- package/skills/self-configuration/SKILL.md +4 -4
- package/scripts/codex-watch/check-release.ts +0 -128
- package/scripts/codex-watch/render-issue.ts +0 -273
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const RUNTIME_SANDBOX_IMAGE =
|
|
4
|
+
"node:22.18.0-bookworm@sha256:3266bc9e8bee1acc8a77386eefaf574987d2729b8c5ec35b0dbd6ddbc40b0ce2";
|
|
5
|
+
const AUTH_ENV_KEYS = ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] as const;
|
|
6
|
+
|
|
7
|
+
export interface CommandSpec {
|
|
8
|
+
command: string;
|
|
9
|
+
args: string[];
|
|
10
|
+
cwd: string;
|
|
11
|
+
env: NodeJS.ProcessEnv;
|
|
12
|
+
timeoutMs: number;
|
|
13
|
+
outputCapBytes: number;
|
|
14
|
+
stdin?: string;
|
|
15
|
+
label: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CommandResult {
|
|
19
|
+
exitCode: number | null;
|
|
20
|
+
stdout: string;
|
|
21
|
+
stderr: string;
|
|
22
|
+
timedOut: boolean;
|
|
23
|
+
truncated: boolean;
|
|
24
|
+
signal?: NodeJS.Signals | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type CommandRunner = (spec: CommandSpec) => Promise<CommandResult>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Runs untrusted package install scripts and the closed-source Claude binary in
|
|
31
|
+
* a container that can see only its disposable probe root. The Docker client
|
|
32
|
+
* receives only the already-minimized command environment; secret values are
|
|
33
|
+
* inherited by name rather than placed in argv.
|
|
34
|
+
*/
|
|
35
|
+
export function sandboxClaudeCommand(
|
|
36
|
+
spec: CommandSpec,
|
|
37
|
+
root: string,
|
|
38
|
+
): CommandSpec {
|
|
39
|
+
const containerRoot = "/watch";
|
|
40
|
+
const mapPath = (value: string): string =>
|
|
41
|
+
value === root || value.startsWith(`${root}/`)
|
|
42
|
+
? `${containerRoot}${value.slice(root.length)}`
|
|
43
|
+
: value;
|
|
44
|
+
const args = [
|
|
45
|
+
"run",
|
|
46
|
+
"--rm",
|
|
47
|
+
"--network",
|
|
48
|
+
"bridge",
|
|
49
|
+
"--read-only",
|
|
50
|
+
"--tmpfs",
|
|
51
|
+
"/tmp:rw,nosuid,nodev,size=128m",
|
|
52
|
+
"--cap-drop",
|
|
53
|
+
"ALL",
|
|
54
|
+
"--security-opt",
|
|
55
|
+
"no-new-privileges",
|
|
56
|
+
"--pids-limit",
|
|
57
|
+
"256",
|
|
58
|
+
"--user",
|
|
59
|
+
`${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`,
|
|
60
|
+
"--memory",
|
|
61
|
+
"2g",
|
|
62
|
+
"--cpus",
|
|
63
|
+
"2",
|
|
64
|
+
"--volume",
|
|
65
|
+
`${root}:${containerRoot}:rw`,
|
|
66
|
+
"--workdir",
|
|
67
|
+
mapPath(spec.cwd),
|
|
68
|
+
];
|
|
69
|
+
for (const [key, value] of Object.entries(spec.env)) {
|
|
70
|
+
if (value === undefined || key === "PATH") continue;
|
|
71
|
+
args.push(
|
|
72
|
+
"--env",
|
|
73
|
+
AUTH_ENV_KEYS.includes(key as (typeof AUTH_ENV_KEYS)[number])
|
|
74
|
+
? key
|
|
75
|
+
: `${key}=${mapPath(value)}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
args.push(
|
|
79
|
+
RUNTIME_SANDBOX_IMAGE,
|
|
80
|
+
mapPath(spec.command),
|
|
81
|
+
...spec.args.map(mapPath),
|
|
82
|
+
);
|
|
83
|
+
return {
|
|
84
|
+
...spec,
|
|
85
|
+
command: "/usr/bin/docker",
|
|
86
|
+
args,
|
|
87
|
+
cwd: root,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Spawn with bounded output and terminate the whole process group on timeout/cap. */
|
|
92
|
+
export const runBoundedCommand: CommandRunner = (spec) =>
|
|
93
|
+
new Promise((resolvePromise, reject) => {
|
|
94
|
+
const child = spawn(spec.command, spec.args, {
|
|
95
|
+
cwd: spec.cwd,
|
|
96
|
+
env: spec.env,
|
|
97
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
98
|
+
detached: process.platform !== "win32",
|
|
99
|
+
windowsHide: true,
|
|
100
|
+
});
|
|
101
|
+
const chunks: { stdout: Buffer[]; stderr: Buffer[] } = {
|
|
102
|
+
stdout: [],
|
|
103
|
+
stderr: [],
|
|
104
|
+
};
|
|
105
|
+
let bytes = 0;
|
|
106
|
+
let timedOut = false;
|
|
107
|
+
let truncated = false;
|
|
108
|
+
let settled = false;
|
|
109
|
+
const terminate = () => {
|
|
110
|
+
if (child.pid === undefined) return;
|
|
111
|
+
try {
|
|
112
|
+
if (process.platform === "win32") child.kill("SIGTERM");
|
|
113
|
+
else process.kill(-child.pid, "SIGTERM");
|
|
114
|
+
} catch {
|
|
115
|
+
child.kill("SIGTERM");
|
|
116
|
+
}
|
|
117
|
+
const killTimer = setTimeout(() => {
|
|
118
|
+
try {
|
|
119
|
+
if (process.platform === "win32") child.kill("SIGKILL");
|
|
120
|
+
else if (child.pid !== undefined) process.kill(-child.pid, "SIGKILL");
|
|
121
|
+
} catch {
|
|
122
|
+
child.kill("SIGKILL");
|
|
123
|
+
}
|
|
124
|
+
}, 500);
|
|
125
|
+
killTimer.unref();
|
|
126
|
+
};
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
timedOut = true;
|
|
129
|
+
terminate();
|
|
130
|
+
}, spec.timeoutMs);
|
|
131
|
+
const collect = (which: "stdout" | "stderr", value: Buffer) => {
|
|
132
|
+
const remaining = spec.outputCapBytes - bytes;
|
|
133
|
+
if (remaining > 0) {
|
|
134
|
+
const kept = value.subarray(0, remaining);
|
|
135
|
+
chunks[which].push(kept);
|
|
136
|
+
bytes += kept.length;
|
|
137
|
+
}
|
|
138
|
+
if (value.length > remaining) {
|
|
139
|
+
truncated = true;
|
|
140
|
+
terminate();
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
child.stdout.on("data", (value: Buffer) => collect("stdout", value));
|
|
144
|
+
child.stderr.on("data", (value: Buffer) => collect("stderr", value));
|
|
145
|
+
child.on("error", (error) => {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
if (!settled) {
|
|
148
|
+
settled = true;
|
|
149
|
+
reject(
|
|
150
|
+
new Error(`Command ${spec.label} could not start: ${error.message}`),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
child.on("close", (exitCode, signal) => {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
if (!settled) {
|
|
157
|
+
settled = true;
|
|
158
|
+
resolvePromise({
|
|
159
|
+
exitCode,
|
|
160
|
+
signal,
|
|
161
|
+
stdout: Buffer.concat(chunks.stdout).toString("utf8"),
|
|
162
|
+
stderr: Buffer.concat(chunks.stderr).toString("utf8"),
|
|
163
|
+
timedOut,
|
|
164
|
+
truncated,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
if (spec.stdin !== undefined) child.stdin.end(spec.stdin);
|
|
169
|
+
else child.stdin.end();
|
|
170
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
CLAUDE_WATCH_STATE_BRANCH,
|
|
8
|
+
CLAUDE_WATCH_STATE_FILE,
|
|
9
|
+
finalizeStateBranch,
|
|
10
|
+
loadStateSnapshot,
|
|
11
|
+
loadStateSnapshotAtCommit,
|
|
12
|
+
materializeStateFiles,
|
|
13
|
+
resolveStateBranchTip,
|
|
14
|
+
} from "./state-branch.ts";
|
|
15
|
+
import {
|
|
16
|
+
CLAUDE_WATCH_STATE_SCHEMA_VERSION,
|
|
17
|
+
type ClaudeWatchStateSnapshot,
|
|
18
|
+
} from "./types.ts";
|
|
19
|
+
|
|
20
|
+
function git(cwd: string, ...args: string[]): string {
|
|
21
|
+
const result = spawnSync("git", ["-C", cwd, ...args], {
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
env: {
|
|
24
|
+
...process.env,
|
|
25
|
+
GIT_AUTHOR_NAME: "Test",
|
|
26
|
+
GIT_AUTHOR_EMAIL: "test@example.test",
|
|
27
|
+
GIT_COMMITTER_NAME: "Test",
|
|
28
|
+
GIT_COMMITTER_EMAIL: "test@example.test",
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
if (result.status !== 0) {
|
|
32
|
+
throw new Error(result.stderr || `git ${args.join(" ")} failed`);
|
|
33
|
+
}
|
|
34
|
+
return result.stdout.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function snapshot(candidateId: string): ClaudeWatchStateSnapshot {
|
|
38
|
+
return {
|
|
39
|
+
schema_version: CLAUDE_WATCH_STATE_SCHEMA_VERSION,
|
|
40
|
+
candidate_id: candidateId,
|
|
41
|
+
package_version: candidateId,
|
|
42
|
+
npm_integrity: `sha512-${candidateId}`,
|
|
43
|
+
npm_published_at: "2026-08-01T00:00:00Z",
|
|
44
|
+
release_url: `https://example.test/releases/${candidateId}`,
|
|
45
|
+
release_notes_md: "release notes",
|
|
46
|
+
release_published_at: "2026-08-01T00:00:00Z",
|
|
47
|
+
dist_tags: { latest: candidateId, stable: candidateId, next: null },
|
|
48
|
+
docs: {
|
|
49
|
+
index_url: "https://example.test/docs",
|
|
50
|
+
index_hash: "index-hash",
|
|
51
|
+
digest: `docs-${candidateId}`,
|
|
52
|
+
full_scan: true,
|
|
53
|
+
scanned_at: "2026-08-01T00:00:00Z",
|
|
54
|
+
pages: {},
|
|
55
|
+
},
|
|
56
|
+
runtime: null,
|
|
57
|
+
fetched_at: "2026-08-01T00:00:00Z",
|
|
58
|
+
workflow_run_url: "https://example.test/actions/1",
|
|
59
|
+
state_commit_parent: null,
|
|
60
|
+
analysis_parent_sha: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function repository(): { root: string; remote: string; clone: string } {
|
|
65
|
+
const root = mkdtempSync(join(tmpdir(), "claude-state-test-"));
|
|
66
|
+
const remote = join(root, "remote.git");
|
|
67
|
+
const clone = join(root, "clone");
|
|
68
|
+
git(root, "init", "--bare", remote);
|
|
69
|
+
git(root, "clone", remote, clone);
|
|
70
|
+
git(clone, "checkout", "-b", "main");
|
|
71
|
+
git(clone, "commit", "--allow-empty", "-m", "initial main");
|
|
72
|
+
git(clone, "push", "-u", "origin", "main");
|
|
73
|
+
return { root, remote, clone };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("Claude state branch", () => {
|
|
77
|
+
test("materializes and safely loads a deterministic snapshot", () => {
|
|
78
|
+
const value = snapshot("2.1.0");
|
|
79
|
+
const files = materializeStateFiles(value, {
|
|
80
|
+
"runtime/output.json": "{}\n",
|
|
81
|
+
"docs/index.json": "[]\n",
|
|
82
|
+
[CLAUDE_WATCH_STATE_FILE]: "must be replaced",
|
|
83
|
+
});
|
|
84
|
+
expect(Object.keys(files)).toEqual([
|
|
85
|
+
"docs/index.json",
|
|
86
|
+
"runtime/output.json",
|
|
87
|
+
CLAUDE_WATCH_STATE_FILE,
|
|
88
|
+
]);
|
|
89
|
+
expect(loadStateSnapshot(files)).toEqual(value);
|
|
90
|
+
expect(loadStateSnapshot({ [CLAUDE_WATCH_STATE_FILE]: "{" })).toBeNull();
|
|
91
|
+
expect(() => materializeStateFiles(value, { "../escape": "no" })).toThrow(
|
|
92
|
+
"Unsafe state file path",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("creates an orphan state branch, advances by parent, and is idempotent", () => {
|
|
97
|
+
const repo = repository();
|
|
98
|
+
try {
|
|
99
|
+
const mainBefore = git(repo.clone, "rev-parse", "main");
|
|
100
|
+
const first = finalizeStateBranch({
|
|
101
|
+
repoPath: repo.clone,
|
|
102
|
+
snapshot: snapshot("2.1.0"),
|
|
103
|
+
files: { "docs/page.txt": "first\n" },
|
|
104
|
+
expectedBaseSha: null,
|
|
105
|
+
});
|
|
106
|
+
expect(first.created).toBe(true);
|
|
107
|
+
expect(first.parentSha).toBeNull();
|
|
108
|
+
expect(
|
|
109
|
+
git(repo.clone, "rev-list", "--parents", "-n", "1", first.commitSha),
|
|
110
|
+
).toBe(first.commitSha);
|
|
111
|
+
expect(resolveStateBranchTip(repo.clone)).toBe(first.commitSha);
|
|
112
|
+
expect(
|
|
113
|
+
loadStateSnapshotAtCommit(repo.clone, first.commitSha),
|
|
114
|
+
).toMatchObject({
|
|
115
|
+
candidate_id: "2.1.0",
|
|
116
|
+
state_commit_parent: null,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const retry = finalizeStateBranch({
|
|
120
|
+
repoPath: repo.clone,
|
|
121
|
+
snapshot: snapshot("2.1.0"),
|
|
122
|
+
expectedBaseSha: null,
|
|
123
|
+
});
|
|
124
|
+
expect(retry).toEqual({
|
|
125
|
+
commitSha: first.commitSha,
|
|
126
|
+
created: false,
|
|
127
|
+
parentSha: first.commitSha,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const second = finalizeStateBranch({
|
|
131
|
+
repoPath: repo.clone,
|
|
132
|
+
snapshot: snapshot("2.2.0"),
|
|
133
|
+
expectedBaseSha: first.commitSha,
|
|
134
|
+
});
|
|
135
|
+
expect(second.parentSha).toBe(first.commitSha);
|
|
136
|
+
expect(
|
|
137
|
+
git(repo.clone, "rev-list", "--parents", "-n", "1", second.commitSha),
|
|
138
|
+
).toBe(`${second.commitSha} ${first.commitSha}`);
|
|
139
|
+
expect(
|
|
140
|
+
loadStateSnapshotAtCommit(repo.clone, second.commitSha),
|
|
141
|
+
).toMatchObject({
|
|
142
|
+
candidate_id: "2.2.0",
|
|
143
|
+
state_commit_parent: first.commitSha,
|
|
144
|
+
analysis_parent_sha: null,
|
|
145
|
+
});
|
|
146
|
+
expect(git(repo.clone, "rev-parse", "main")).toBe(mainBefore);
|
|
147
|
+
expect(git(repo.clone, "branch", "--show-current")).toBe("main");
|
|
148
|
+
expect(
|
|
149
|
+
git(repo.clone, "ls-remote", "--heads", "origin", "main"),
|
|
150
|
+
).toContain(mainBefore);
|
|
151
|
+
expect(
|
|
152
|
+
git(
|
|
153
|
+
repo.clone,
|
|
154
|
+
"ls-remote",
|
|
155
|
+
"--heads",
|
|
156
|
+
"origin",
|
|
157
|
+
CLAUDE_WATCH_STATE_BRANCH,
|
|
158
|
+
),
|
|
159
|
+
).toContain(second.commitSha);
|
|
160
|
+
} finally {
|
|
161
|
+
rmSync(repo.root, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("rejects a stale base for a different candidate", () => {
|
|
166
|
+
const repo = repository();
|
|
167
|
+
try {
|
|
168
|
+
const first = finalizeStateBranch({
|
|
169
|
+
repoPath: repo.clone,
|
|
170
|
+
snapshot: snapshot("2.1.0"),
|
|
171
|
+
expectedBaseSha: null,
|
|
172
|
+
});
|
|
173
|
+
expect(() =>
|
|
174
|
+
finalizeStateBranch({
|
|
175
|
+
repoPath: repo.clone,
|
|
176
|
+
snapshot: snapshot("2.2.0"),
|
|
177
|
+
expectedBaseSha: null,
|
|
178
|
+
}),
|
|
179
|
+
).toThrow(`found ${first.commitSha}`);
|
|
180
|
+
} finally {
|
|
181
|
+
rmSync(repo.root, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("a rejected push leaves main and the durable state tip unchanged", () => {
|
|
186
|
+
const repo = repository();
|
|
187
|
+
try {
|
|
188
|
+
const first = finalizeStateBranch({
|
|
189
|
+
repoPath: repo.clone,
|
|
190
|
+
snapshot: snapshot("2.1.0"),
|
|
191
|
+
expectedBaseSha: null,
|
|
192
|
+
});
|
|
193
|
+
const mainBefore = git(repo.clone, "rev-parse", "main");
|
|
194
|
+
const hook = join(repo.remote, "hooks", "pre-receive");
|
|
195
|
+
writeFileSync(hook, "#!/bin/sh\necho rejected >&2\nexit 1\n");
|
|
196
|
+
chmodSync(hook, 0o755);
|
|
197
|
+
|
|
198
|
+
expect(() =>
|
|
199
|
+
finalizeStateBranch({
|
|
200
|
+
repoPath: repo.clone,
|
|
201
|
+
snapshot: snapshot("2.2.0"),
|
|
202
|
+
expectedBaseSha: first.commitSha,
|
|
203
|
+
}),
|
|
204
|
+
).toThrow("rejected");
|
|
205
|
+
expect(resolveStateBranchTip(repo.clone)).toBe(first.commitSha);
|
|
206
|
+
expect(git(repo.clone, "rev-parse", "main")).toBe(mainBefore);
|
|
207
|
+
} finally {
|
|
208
|
+
rmSync(repo.root, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, posix } from "node:path";
|
|
5
|
+
import type { ClaudeWatchStateSnapshot } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export const CLAUDE_WATCH_STATE_BRANCH = "claude-watch-state";
|
|
8
|
+
export const CLAUDE_WATCH_STATE_FILE = "snapshot.json";
|
|
9
|
+
|
|
10
|
+
export interface FinalizeStateBranchOptions {
|
|
11
|
+
repoPath: string;
|
|
12
|
+
snapshot: ClaudeWatchStateSnapshot;
|
|
13
|
+
files?: Readonly<Record<string, string>>;
|
|
14
|
+
/** The SHA observed when analysis started; null means no state branch existed. */
|
|
15
|
+
expectedBaseSha: string | null;
|
|
16
|
+
remote?: string;
|
|
17
|
+
branch?: string;
|
|
18
|
+
commitMessage?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FinalizeStateBranchResult {
|
|
22
|
+
commitSha: string;
|
|
23
|
+
created: boolean;
|
|
24
|
+
parentSha: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Produces the complete, deterministic file set for a state commit. This is
|
|
29
|
+
* intentionally pure so callers can inspect or test a snapshot before git is
|
|
30
|
+
* involved.
|
|
31
|
+
*/
|
|
32
|
+
export function materializeStateFiles(
|
|
33
|
+
snapshot: ClaudeWatchStateSnapshot,
|
|
34
|
+
files: Readonly<Record<string, string>> = {},
|
|
35
|
+
): Record<string, string> {
|
|
36
|
+
const result: Record<string, string> = {};
|
|
37
|
+
for (const path of Object.keys(files).sort()) {
|
|
38
|
+
assertSafeStatePath(path);
|
|
39
|
+
if (path === CLAUDE_WATCH_STATE_FILE) continue;
|
|
40
|
+
result[path] = files[path] as string;
|
|
41
|
+
}
|
|
42
|
+
result[CLAUDE_WATCH_STATE_FILE] = `${JSON.stringify(snapshot, null, 2)}\n`;
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Safely loads the canonical snapshot from an already materialized file map. */
|
|
47
|
+
export function loadStateSnapshot(
|
|
48
|
+
files: Readonly<Record<string, string>>,
|
|
49
|
+
): ClaudeWatchStateSnapshot | null {
|
|
50
|
+
const source = files[CLAUDE_WATCH_STATE_FILE];
|
|
51
|
+
if (typeof source !== "string") return null;
|
|
52
|
+
try {
|
|
53
|
+
const value: unknown = JSON.parse(source);
|
|
54
|
+
return isStateSnapshot(value) ? value : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Reads a snapshot at a commit without checking out or changing any branch. */
|
|
61
|
+
export function loadStateSnapshotAtCommit(
|
|
62
|
+
repoPath: string,
|
|
63
|
+
commitSha: string,
|
|
64
|
+
): ClaudeWatchStateSnapshot | null {
|
|
65
|
+
const result = git(
|
|
66
|
+
repoPath,
|
|
67
|
+
["show", `${commitSha}:${CLAUDE_WATCH_STATE_FILE}`],
|
|
68
|
+
false,
|
|
69
|
+
);
|
|
70
|
+
if (!result.ok) return null;
|
|
71
|
+
return loadStateSnapshot({ [CLAUDE_WATCH_STATE_FILE]: result.stdout });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Reads the complete sanitized state tree at a commit without checking it out. */
|
|
75
|
+
export function loadStateFilesAtCommit(
|
|
76
|
+
repoPath: string,
|
|
77
|
+
commitSha: string,
|
|
78
|
+
): Record<string, string> {
|
|
79
|
+
const listed = git(
|
|
80
|
+
repoPath,
|
|
81
|
+
["ls-tree", "-r", "--name-only", commitSha],
|
|
82
|
+
false,
|
|
83
|
+
);
|
|
84
|
+
if (!listed.ok) return {};
|
|
85
|
+
const files: Record<string, string> = {};
|
|
86
|
+
for (const path of listed.stdout.split("\n").filter(Boolean).sort()) {
|
|
87
|
+
assertSafeStatePath(path);
|
|
88
|
+
const contents = git(repoPath, ["show", `${commitSha}:${path}`], false);
|
|
89
|
+
if (contents.ok) files[path] = contents.stdout;
|
|
90
|
+
}
|
|
91
|
+
return files;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Returns the remote state tip, or null when the branch has not been created. */
|
|
95
|
+
export function resolveStateBranchTip(
|
|
96
|
+
repoPath: string,
|
|
97
|
+
remote = "origin",
|
|
98
|
+
branch = CLAUDE_WATCH_STATE_BRANCH,
|
|
99
|
+
): string | null {
|
|
100
|
+
assertBranchName(branch);
|
|
101
|
+
const result = git(repoPath, [
|
|
102
|
+
"ls-remote",
|
|
103
|
+
"--heads",
|
|
104
|
+
remote,
|
|
105
|
+
`refs/heads/${branch}`,
|
|
106
|
+
]);
|
|
107
|
+
const line = result.stdout.trim();
|
|
108
|
+
if (!line) return null;
|
|
109
|
+
const sha = line.split(/\s+/, 1)[0];
|
|
110
|
+
if (!sha || !/^[0-9a-f]{40,64}$/.test(sha)) {
|
|
111
|
+
throw new Error(`Unexpected state branch response for ${branch}`);
|
|
112
|
+
}
|
|
113
|
+
return sha;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Fetches the current state tip into a remote-tracking ref without checkout. */
|
|
117
|
+
export function fetchStateBranchTip(
|
|
118
|
+
repoPath: string,
|
|
119
|
+
remote = "origin",
|
|
120
|
+
branch = CLAUDE_WATCH_STATE_BRANCH,
|
|
121
|
+
): string | null {
|
|
122
|
+
const tip = resolveStateBranchTip(repoPath, remote, branch);
|
|
123
|
+
if (!tip) return null;
|
|
124
|
+
git(repoPath, [
|
|
125
|
+
"fetch",
|
|
126
|
+
"--no-tags",
|
|
127
|
+
"--depth=2",
|
|
128
|
+
remote,
|
|
129
|
+
`refs/heads/${branch}:refs/remotes/${remote}/${branch}`,
|
|
130
|
+
]);
|
|
131
|
+
return tip;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Commits and pushes a snapshot using git plumbing only. No branch is checked
|
|
136
|
+
* out, so the caller's current branch (especially main) is never touched.
|
|
137
|
+
* Pushes have no force refspec and therefore receive normal fast-forward
|
|
138
|
+
* protection from git.
|
|
139
|
+
*/
|
|
140
|
+
export function finalizeStateBranch(
|
|
141
|
+
options: FinalizeStateBranchOptions,
|
|
142
|
+
): FinalizeStateBranchResult {
|
|
143
|
+
const remote = options.remote ?? "origin";
|
|
144
|
+
const branch = options.branch ?? CLAUDE_WATCH_STATE_BRANCH;
|
|
145
|
+
assertBranchName(branch);
|
|
146
|
+
|
|
147
|
+
const currentTip = resolveStateBranchTip(options.repoPath, remote, branch);
|
|
148
|
+
if (currentTip) fetchStateBranchTip(options.repoPath, remote, branch);
|
|
149
|
+
const currentSnapshot = currentTip
|
|
150
|
+
? loadStateSnapshotAtCommit(options.repoPath, currentTip)
|
|
151
|
+
: null;
|
|
152
|
+
|
|
153
|
+
// A retry after a successful push is a no-op, even if its remembered base is
|
|
154
|
+
// now stale. This check is what makes partial workflow reconciliation safe.
|
|
155
|
+
if (
|
|
156
|
+
currentTip &&
|
|
157
|
+
currentSnapshot?.candidate_id === options.snapshot.candidate_id
|
|
158
|
+
) {
|
|
159
|
+
return { commitSha: currentTip, created: false, parentSha: currentTip };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (currentTip !== options.expectedBaseSha) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Claude state branch advanced: expected ${options.expectedBaseSha ?? "no branch"}, found ${currentTip ?? "no branch"}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const snapshot: ClaudeWatchStateSnapshot = {
|
|
169
|
+
...options.snapshot,
|
|
170
|
+
state_commit_parent: currentTip,
|
|
171
|
+
};
|
|
172
|
+
const files = materializeStateFiles(snapshot, options.files);
|
|
173
|
+
const temporaryDirectory = mkdtempSync(join(tmpdir(), "claude-watch-state-"));
|
|
174
|
+
const indexFile = join(temporaryDirectory, "index");
|
|
175
|
+
const env = {
|
|
176
|
+
...process.env,
|
|
177
|
+
GIT_INDEX_FILE: indexFile,
|
|
178
|
+
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME ?? "Claude Watch",
|
|
179
|
+
GIT_AUTHOR_EMAIL:
|
|
180
|
+
process.env.GIT_AUTHOR_EMAIL ?? "claude-watch@users.noreply.github.com",
|
|
181
|
+
GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME ?? "Claude Watch",
|
|
182
|
+
GIT_COMMITTER_EMAIL:
|
|
183
|
+
process.env.GIT_COMMITTER_EMAIL ??
|
|
184
|
+
"claude-watch@users.noreply.github.com",
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
git(options.repoPath, ["read-tree", "--empty"], true, env);
|
|
189
|
+
for (const [path, contents] of Object.entries(files)) {
|
|
190
|
+
const blob = git(
|
|
191
|
+
options.repoPath,
|
|
192
|
+
["hash-object", "-w", "--stdin"],
|
|
193
|
+
true,
|
|
194
|
+
env,
|
|
195
|
+
contents,
|
|
196
|
+
).stdout.trim();
|
|
197
|
+
git(
|
|
198
|
+
options.repoPath,
|
|
199
|
+
["update-index", "--add", "--cacheinfo", "100644", blob, path],
|
|
200
|
+
true,
|
|
201
|
+
env,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const tree = git(options.repoPath, ["write-tree"], true, env).stdout.trim();
|
|
205
|
+
const commitArgs = [
|
|
206
|
+
"commit-tree",
|
|
207
|
+
tree,
|
|
208
|
+
...(currentTip ? ["-p", currentTip] : []),
|
|
209
|
+
"-m",
|
|
210
|
+
options.commitMessage ??
|
|
211
|
+
`chore(claude-watch): record ${snapshot.candidate_id}`,
|
|
212
|
+
];
|
|
213
|
+
const commitSha = git(
|
|
214
|
+
options.repoPath,
|
|
215
|
+
commitArgs,
|
|
216
|
+
true,
|
|
217
|
+
env,
|
|
218
|
+
).stdout.trim();
|
|
219
|
+
|
|
220
|
+
// No leading '+' and no --force: the remote enforces normal FF updates.
|
|
221
|
+
git(options.repoPath, [
|
|
222
|
+
"push",
|
|
223
|
+
remote,
|
|
224
|
+
`${commitSha}:refs/heads/${branch}`,
|
|
225
|
+
]);
|
|
226
|
+
return { commitSha, created: true, parentSha: currentTip };
|
|
227
|
+
} finally {
|
|
228
|
+
rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
interface GitResult {
|
|
233
|
+
ok: boolean;
|
|
234
|
+
stdout: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function git(
|
|
238
|
+
repoPath: string,
|
|
239
|
+
args: string[],
|
|
240
|
+
required = true,
|
|
241
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
242
|
+
input?: string,
|
|
243
|
+
): GitResult {
|
|
244
|
+
const result = spawnSync("git", ["-C", repoPath, ...args], {
|
|
245
|
+
encoding: "utf8",
|
|
246
|
+
env,
|
|
247
|
+
input,
|
|
248
|
+
});
|
|
249
|
+
const stdout = result.stdout ?? "";
|
|
250
|
+
if (required && result.status !== 0) {
|
|
251
|
+
const detail = (result.stderr ?? stdout).trim();
|
|
252
|
+
throw new Error(`git ${args[0]} failed${detail ? `: ${detail}` : ""}`);
|
|
253
|
+
}
|
|
254
|
+
return { ok: result.status === 0, stdout };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function assertSafeStatePath(path: string): void {
|
|
258
|
+
if (
|
|
259
|
+
!path ||
|
|
260
|
+
path.includes("\\") ||
|
|
261
|
+
posix.isAbsolute(path) ||
|
|
262
|
+
posix.normalize(path) !== path ||
|
|
263
|
+
path === ".." ||
|
|
264
|
+
path.startsWith("../")
|
|
265
|
+
) {
|
|
266
|
+
throw new Error(`Unsafe state file path: ${path}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function assertBranchName(branch: string): void {
|
|
271
|
+
if (
|
|
272
|
+
!branch ||
|
|
273
|
+
branch.startsWith("-") ||
|
|
274
|
+
branch.includes("..") ||
|
|
275
|
+
!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(branch)
|
|
276
|
+
) {
|
|
277
|
+
throw new Error(`Unsafe state branch name: ${branch}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isStateSnapshot(value: unknown): value is ClaudeWatchStateSnapshot {
|
|
282
|
+
if (!isRecord(value)) return false;
|
|
283
|
+
return (
|
|
284
|
+
typeof value.schema_version === "number" &&
|
|
285
|
+
typeof value.candidate_id === "string" &&
|
|
286
|
+
typeof value.package_version === "string" &&
|
|
287
|
+
typeof value.npm_integrity === "string" &&
|
|
288
|
+
typeof value.npm_published_at === "string" &&
|
|
289
|
+
typeof value.release_url === "string" &&
|
|
290
|
+
typeof value.release_notes_md === "string" &&
|
|
291
|
+
typeof value.release_published_at === "string" &&
|
|
292
|
+
isDistTags(value.dist_tags) &&
|
|
293
|
+
isRecord(value.docs) &&
|
|
294
|
+
(isRecord(value.runtime) || value.runtime === null) &&
|
|
295
|
+
typeof value.fetched_at === "string" &&
|
|
296
|
+
typeof value.workflow_run_url === "string" &&
|
|
297
|
+
(typeof value.state_commit_parent === "string" ||
|
|
298
|
+
value.state_commit_parent === null) &&
|
|
299
|
+
(value.analysis_parent_sha === undefined ||
|
|
300
|
+
typeof value.analysis_parent_sha === "string" ||
|
|
301
|
+
value.analysis_parent_sha === null)
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isDistTags(value: unknown): boolean {
|
|
306
|
+
return (
|
|
307
|
+
isRecord(value) &&
|
|
308
|
+
typeof value.latest === "string" &&
|
|
309
|
+
(typeof value.stable === "string" || value.stable === null) &&
|
|
310
|
+
(typeof value.next === "string" || value.next === null)
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
315
|
+
return typeof value === "object" && value !== null;
|
|
316
|
+
}
|