talon-agent 5.26.3 → 5.26.5
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/package.json +1 -1
- package/src/app.ts +8 -0
- package/src/backend/agy/auth.ts +2 -2
- package/src/backend/agy/mcp/config.ts +3 -3
- package/src/backend/claude-sdk/usage/plan-usage.ts +2 -2
- package/src/backend/codex/discovery.ts +2 -2
- package/src/backend/codex/plan-usage.ts +2 -2
- package/src/backend/codex/token-usage.ts +2 -2
- package/src/backend/remote-server/sessions.ts +82 -13
- package/src/backend/runtime/turn/handle-retry.ts +6 -5
- package/src/cli/commands/backup.ts +7 -3
- package/src/core/auth/status.ts +3 -3
- package/src/core/backup/archive/verify.ts +53 -0
- package/src/core/backup/plan.ts +7 -4
- package/src/core/backup/restore.ts +3 -3
- package/src/core/backup/retention/policy.ts +195 -0
- package/src/core/backup/scheduler.ts +10 -4
- package/src/core/backup/snapshot.ts +6 -2
- package/src/core/backup/sources/sessions.ts +29 -0
- package/src/core/backup/status.ts +7 -0
- package/src/core/backup/store.ts +20 -25
- package/src/core/backup/types.ts +14 -0
- package/src/core/backup/upload.ts +37 -20
- package/src/core/config/index.ts +26 -1
- package/src/core/engine/gateway-actions/native/params.ts +3 -3
- package/src/core/errors.ts +13 -2
- package/src/core/layout/claude-relink.ts +131 -0
- package/src/core/layout/index.ts +170 -0
- package/src/core/layout/mounts.ts +129 -0
- package/src/core/layout/persistence.ts +132 -0
- package/src/frontend/presentation/backup-panel.ts +7 -1
- package/src/plugins/mempalace/provision.ts +4 -4
- package/src/plugins/playwright/provision.ts +2 -2
- package/src/util/fs-path.ts +103 -3
- package/src/util/paths.ts +6 -2
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep Claude transcripts resumable when the Talon home moves.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code files a session's transcript under
|
|
5
|
+
* `~/.claude/projects/<slug>/`, where the slug IS the absolute cwd with
|
|
6
|
+
* every non-alphanumeric character turned into `-`. Talon's cwd is
|
|
7
|
+
* `<TALON_HOME>/workspace`, so moving the home — `/home/bun/.talon` to
|
|
8
|
+
* `/data/.talon` when a container switches to the single data root, or a
|
|
9
|
+
* new TALON_HOME on a host — changes the slug, and every stored session
|
|
10
|
+
* id then points at a transcript Claude can no longer find.
|
|
11
|
+
*
|
|
12
|
+
* This copies the old slug directories' files under the new slug. It
|
|
13
|
+
* never overwrites a file that already exists there and never moves or
|
|
14
|
+
* deletes the originals: the old directories stay as they were.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { constants } from "node:fs";
|
|
18
|
+
import { copyFile, mkdir, readdir } from "node:fs/promises";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { relocateClaudeSlug } from "../backup/sources/relocate.js";
|
|
21
|
+
import { claudeProjectSlug, sessionCwds } from "../backup/sources/sessions.js";
|
|
22
|
+
|
|
23
|
+
export type RelinkResult = {
|
|
24
|
+
/** The old slug directory, left in place. */
|
|
25
|
+
from: string;
|
|
26
|
+
/** The directory the current home's sessions are looked up in. */
|
|
27
|
+
to: string;
|
|
28
|
+
/** Files copied (existing ones are skipped, never replaced). */
|
|
29
|
+
copied: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function matchesSlugOf(name: string, home: string): boolean {
|
|
33
|
+
return sessionCwds(home)
|
|
34
|
+
.map(claudeProjectSlug)
|
|
35
|
+
.some((slug) => name === slug || name.startsWith(`${slug}-`));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Copy `src` into `dst` recursively; existing files win. Returns files copied. */
|
|
39
|
+
export async function mergeCopy(src: string, dst: string): Promise<number> {
|
|
40
|
+
await mkdir(dst, { recursive: true });
|
|
41
|
+
let copied = 0;
|
|
42
|
+
for (const entry of await readdir(src, { withFileTypes: true })) {
|
|
43
|
+
const from = join(src, entry.name);
|
|
44
|
+
const to = join(dst, entry.name);
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
copied += await mergeCopy(from, to);
|
|
47
|
+
} else if (entry.isFile()) {
|
|
48
|
+
try {
|
|
49
|
+
await copyFile(from, to, constants.COPYFILE_EXCL);
|
|
50
|
+
copied++;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return copied;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function listDir(dir: string): Promise<string[]> {
|
|
60
|
+
try {
|
|
61
|
+
return (await readdir(dir, { withFileTypes: true }))
|
|
62
|
+
.filter((e) => e.isDirectory())
|
|
63
|
+
.map((e) => e.name)
|
|
64
|
+
.sort();
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Old-slug directories to carry over: every project dir, in any of the
|
|
72
|
+
* `sources` projects directories, that belongs to one of `previousHomes`
|
|
73
|
+
* and not to `home`.
|
|
74
|
+
*/
|
|
75
|
+
async function findOldSlugDirs(input: {
|
|
76
|
+
home: string;
|
|
77
|
+
previousHomes: readonly string[];
|
|
78
|
+
sources: readonly string[];
|
|
79
|
+
}): Promise<{ from: string; name: string; previousHome: string }[]> {
|
|
80
|
+
const found: { from: string; name: string; previousHome: string }[] = [];
|
|
81
|
+
for (const dir of new Set(input.sources)) {
|
|
82
|
+
const names = await listDir(dir);
|
|
83
|
+
for (const previousHome of new Set(input.previousHomes)) {
|
|
84
|
+
if (previousHome === input.home) continue;
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
if (!matchesSlugOf(name, previousHome)) continue;
|
|
87
|
+
if (matchesSlugOf(name, input.home)) continue;
|
|
88
|
+
found.push({ from: join(dir, name), name, previousHome });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return found;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Copy transcripts filed under a previous home's slug to the current
|
|
97
|
+
* home's slug. `projectsDir` is where the current Claude looks;
|
|
98
|
+
* `extraSources` are other projects directories to read from (an old
|
|
99
|
+
* `~/.claude` still mounted during a migration). Sources in `skip` were
|
|
100
|
+
* carried over on an earlier boot. A directory that fails to copy is
|
|
101
|
+
* reported through `onError` and left for the next boot.
|
|
102
|
+
*/
|
|
103
|
+
export async function relinkClaudeProjects(input: {
|
|
104
|
+
home: string;
|
|
105
|
+
projectsDir: string;
|
|
106
|
+
previousHomes: readonly string[];
|
|
107
|
+
extraSources?: readonly string[];
|
|
108
|
+
skip?: ReadonlySet<string>;
|
|
109
|
+
onError?: (from: string, err: unknown) => void;
|
|
110
|
+
}): Promise<RelinkResult[]> {
|
|
111
|
+
const olds = await findOldSlugDirs({
|
|
112
|
+
home: input.home,
|
|
113
|
+
previousHomes: input.previousHomes,
|
|
114
|
+
sources: [input.projectsDir, ...(input.extraSources ?? [])],
|
|
115
|
+
});
|
|
116
|
+
const results: RelinkResult[] = [];
|
|
117
|
+
for (const { from, name, previousHome } of olds) {
|
|
118
|
+
if (input.skip?.has(from)) continue;
|
|
119
|
+
const to = join(
|
|
120
|
+
input.projectsDir,
|
|
121
|
+
relocateClaudeSlug(name, previousHome, input.home),
|
|
122
|
+
);
|
|
123
|
+
if (to === from) continue;
|
|
124
|
+
try {
|
|
125
|
+
results.push({ from, to, copied: await mergeCopy(from, to) });
|
|
126
|
+
} catch (err) {
|
|
127
|
+
input.onError?.(from, err);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return results;
|
|
131
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage layout at boot: keep Claude transcripts reachable across a
|
|
3
|
+
* Talon-home move, and (in a container) warn when a backend's session
|
|
4
|
+
* store is not on a persistent volume. See docs/docker.md.
|
|
5
|
+
*
|
|
6
|
+
* Neither step throws: both are advisory, and a boot must never fail
|
|
7
|
+
* because a check could not read a directory.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile, mkdir } from "node:fs/promises";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import writeFileAtomic from "write-file-atomic";
|
|
14
|
+
import { claudeProjectsDir } from "../backup/sources/sessions.js";
|
|
15
|
+
import { raiseAlert } from "../frontend-runtime/alerts.js";
|
|
16
|
+
import { dirs } from "../../util/paths.js";
|
|
17
|
+
import { log, logWarn } from "../../util/log.js";
|
|
18
|
+
import { relinkClaudeProjects, type RelinkResult } from "./claude-relink.js";
|
|
19
|
+
import { readMountTable, type MountEntry } from "./mounts.js";
|
|
20
|
+
import {
|
|
21
|
+
describeFindings,
|
|
22
|
+
findEphemeralStores,
|
|
23
|
+
inContainer,
|
|
24
|
+
type StorageFinding,
|
|
25
|
+
} from "./persistence.js";
|
|
26
|
+
|
|
27
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Talon homes the published image has used. In a container either may
|
|
31
|
+
* hold the transcripts of a deployment that moved to the other.
|
|
32
|
+
*/
|
|
33
|
+
const CONTAINER_HOMES = ["/home/bun/.talon", "/data/.talon"] as const;
|
|
34
|
+
/** The pre-/data image's HOME, where an old `~/.claude` may still be mounted. */
|
|
35
|
+
const LEGACY_USER_HOME = "/home/bun";
|
|
36
|
+
|
|
37
|
+
/** What the last boot recorded: the home it ran in, dirs already copied. */
|
|
38
|
+
type RelinkState = { home: string; merged: string[] };
|
|
39
|
+
|
|
40
|
+
async function readState(file: string): Promise<RelinkState | null> {
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(await readFile(file, "utf8")) as unknown;
|
|
43
|
+
if (!raw || typeof raw !== "object") return null;
|
|
44
|
+
const { home, merged } = raw as Record<string, unknown>;
|
|
45
|
+
if (typeof home !== "string") return null;
|
|
46
|
+
return {
|
|
47
|
+
home,
|
|
48
|
+
merged: Array.isArray(merged)
|
|
49
|
+
? merged.filter((m): m is string => typeof m === "string")
|
|
50
|
+
: [],
|
|
51
|
+
};
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Carry Claude transcripts over from a previous Talon home's project slug.
|
|
59
|
+
* The previous home comes from the state file (which travels with a copied
|
|
60
|
+
* `.talon`, so it names the old path after a migration) and, in a
|
|
61
|
+
* container, from the image's known homes.
|
|
62
|
+
*/
|
|
63
|
+
export async function relinkAfterHomeMove(opts: {
|
|
64
|
+
home: string;
|
|
65
|
+
userHome: string;
|
|
66
|
+
env: Env;
|
|
67
|
+
container: boolean;
|
|
68
|
+
stateFile: string;
|
|
69
|
+
}): Promise<RelinkResult[]> {
|
|
70
|
+
const state = await readState(opts.stateFile);
|
|
71
|
+
const previousHomes = [
|
|
72
|
+
...(state ? [state.home] : []),
|
|
73
|
+
...(opts.container ? CONTAINER_HOMES : []),
|
|
74
|
+
].filter((h) => h !== opts.home);
|
|
75
|
+
const projectsDir = claudeProjectsDir(opts.userHome, opts.env);
|
|
76
|
+
const legacyProjects = join(LEGACY_USER_HOME, ".claude", "projects");
|
|
77
|
+
const results = await relinkClaudeProjects({
|
|
78
|
+
home: opts.home,
|
|
79
|
+
projectsDir,
|
|
80
|
+
previousHomes,
|
|
81
|
+
extraSources:
|
|
82
|
+
opts.container && legacyProjects !== projectsDir ? [legacyProjects] : [],
|
|
83
|
+
skip: new Set(state?.merged ?? []),
|
|
84
|
+
onError: (from, err) =>
|
|
85
|
+
logWarn(
|
|
86
|
+
"sessions",
|
|
87
|
+
`Could not copy Claude transcripts from ${from}: ${String(err)} (will retry next boot)`,
|
|
88
|
+
),
|
|
89
|
+
});
|
|
90
|
+
for (const r of results) {
|
|
91
|
+
log(
|
|
92
|
+
"sessions",
|
|
93
|
+
`Talon home moved: copied ${r.copied} Claude transcript file(s) from ${r.from} to ${r.to} (the original is kept)`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (state?.home !== opts.home || results.length > 0) {
|
|
97
|
+
const merged = [...(state?.merged ?? []), ...results.map((r) => r.from)];
|
|
98
|
+
await mkdir(dirname(opts.stateFile), { recursive: true });
|
|
99
|
+
await writeFileAtomic(
|
|
100
|
+
opts.stateFile,
|
|
101
|
+
JSON.stringify({ home: opts.home, merged: [...new Set(merged)] }) + "\n",
|
|
102
|
+
{ mode: 0o600 },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return results;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* In a container, raise one admin alert listing every store that would
|
|
110
|
+
* not survive a recreate. Returns the findings (empty outside a container
|
|
111
|
+
* or when the check is switched off with TALON_STORAGE_CHECK=0).
|
|
112
|
+
*/
|
|
113
|
+
export function checkContainerStorage(opts: {
|
|
114
|
+
talonHome: string;
|
|
115
|
+
userHome: string;
|
|
116
|
+
env: Env;
|
|
117
|
+
config: Record<string, unknown>;
|
|
118
|
+
container: boolean;
|
|
119
|
+
mounts?: readonly MountEntry[];
|
|
120
|
+
raise?: typeof raiseAlert;
|
|
121
|
+
}): StorageFinding[] {
|
|
122
|
+
if (!opts.container || opts.env.TALON_STORAGE_CHECK === "0") return [];
|
|
123
|
+
const findings = findEphemeralStores({
|
|
124
|
+
...opts,
|
|
125
|
+
mounts: opts.mounts ?? readMountTable(),
|
|
126
|
+
});
|
|
127
|
+
if (findings.length === 0) return [];
|
|
128
|
+
// Losing only Claude's account file costs a re-onboarding; losing a
|
|
129
|
+
// session store costs every conversation.
|
|
130
|
+
const onlyAccountFile = findings.every((f) =>
|
|
131
|
+
f.path.endsWith(".claude.json"),
|
|
132
|
+
);
|
|
133
|
+
(opts.raise ?? raiseAlert)(
|
|
134
|
+
"storage.ephemeral",
|
|
135
|
+
describeFindings(findings, opts.env),
|
|
136
|
+
{ severity: onlyAccountFile ? "warn" : "error" },
|
|
137
|
+
);
|
|
138
|
+
return findings;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Both steps, with this process's paths. Never throws. */
|
|
142
|
+
export async function runStorageLayoutChecks(
|
|
143
|
+
config: Record<string, unknown>,
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
const env = process.env;
|
|
146
|
+
const container = inContainer(env);
|
|
147
|
+
const userHome = homedir();
|
|
148
|
+
try {
|
|
149
|
+
await relinkAfterHomeMove({
|
|
150
|
+
home: dirs.root,
|
|
151
|
+
userHome,
|
|
152
|
+
env,
|
|
153
|
+
container,
|
|
154
|
+
stateFile: join(dirs.data, "claude-relink.json"),
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
logWarn("sessions", `Claude transcript relink failed: ${String(err)}`);
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
checkContainerStorage({
|
|
161
|
+
talonHome: dirs.root,
|
|
162
|
+
userHome,
|
|
163
|
+
env,
|
|
164
|
+
config,
|
|
165
|
+
container,
|
|
166
|
+
});
|
|
167
|
+
} catch (err) {
|
|
168
|
+
logWarn("bot", `Container storage check failed: ${String(err)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which filesystem a path lives on, read from /proc/self/mountinfo.
|
|
3
|
+
*
|
|
4
|
+
* Inside a container the root filesystem is the image's writable layer:
|
|
5
|
+
* whatever is written there is gone the moment the container is recreated
|
|
6
|
+
* (an image update, a NAS app redeploy). Only bind mounts and named
|
|
7
|
+
* volumes outlive it. This module answers "is this path on one of those?"
|
|
8
|
+
* without shelling out to `mountpoint` or `df`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
12
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** One line of /proc/self/mountinfo, the fields this module needs. */
|
|
15
|
+
export type MountEntry = {
|
|
16
|
+
/** Where it is mounted, in this mount namespace. */
|
|
17
|
+
mountPoint: string;
|
|
18
|
+
/** The directory of the source filesystem that is mounted there. */
|
|
19
|
+
root: string;
|
|
20
|
+
fsType: string;
|
|
21
|
+
source: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Where a path's data lives, as far as surviving a container recreate goes. */
|
|
25
|
+
export type Persistence =
|
|
26
|
+
| "persistent"
|
|
27
|
+
/** The container's own writable layer (or a tmpfs): lost on recreate. */
|
|
28
|
+
| "ephemeral"
|
|
29
|
+
/** An anonymous Docker volume: lost when the container is removed. */
|
|
30
|
+
| "anonymous";
|
|
31
|
+
|
|
32
|
+
/** mountinfo escapes space, tab, newline and backslash as octal. */
|
|
33
|
+
function unescape(field: string): string {
|
|
34
|
+
return field.replace(/\\([0-7]{3})/g, (_, oct: string) =>
|
|
35
|
+
String.fromCharCode(parseInt(oct, 8)),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse mountinfo text (proc(5)): `id parent maj:min root mountpoint opts
|
|
41
|
+
* [optional…] - fstype source superopts`. Malformed lines are skipped.
|
|
42
|
+
*/
|
|
43
|
+
export function parseMountInfo(text: string): MountEntry[] {
|
|
44
|
+
const out: MountEntry[] = [];
|
|
45
|
+
for (const line of text.split("\n")) {
|
|
46
|
+
const fields = line.trim().split(" ");
|
|
47
|
+
const dash = fields.indexOf("-");
|
|
48
|
+
if (fields.length < 6 || dash < 6 || dash + 2 >= fields.length) continue;
|
|
49
|
+
out.push({
|
|
50
|
+
root: unescape(fields[3]),
|
|
51
|
+
mountPoint: unescape(fields[4]),
|
|
52
|
+
fsType: fields[dash + 1],
|
|
53
|
+
source: unescape(fields[dash + 2]),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function covers(mountPoint: string, path: string): boolean {
|
|
60
|
+
if (mountPoint === "/") return true;
|
|
61
|
+
return path === mountPoint || path.startsWith(mountPoint + "/");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The mount a path is on: the longest covering mount point, and among
|
|
66
|
+
* equal ones the last listed (a later mount on the same point shadows the
|
|
67
|
+
* earlier one).
|
|
68
|
+
*/
|
|
69
|
+
export function mountFor(
|
|
70
|
+
path: string,
|
|
71
|
+
mounts: readonly MountEntry[],
|
|
72
|
+
): MountEntry | undefined {
|
|
73
|
+
let best: MountEntry | undefined;
|
|
74
|
+
for (const m of mounts) {
|
|
75
|
+
if (!covers(m.mountPoint, path)) continue;
|
|
76
|
+
if (!best || m.mountPoint.length >= best.mountPoint.length) best = m;
|
|
77
|
+
}
|
|
78
|
+
return best;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Docker names anonymous volumes by a 64-hex id; named ones by name. */
|
|
82
|
+
const ANONYMOUS_VOLUME = /\/volumes\/[0-9a-f]{64}\/_data(\/|$)/;
|
|
83
|
+
|
|
84
|
+
/** Classify the mount a path is on. A missing mount counts as ephemeral. */
|
|
85
|
+
export function classifyMount(mount: MountEntry | undefined): Persistence {
|
|
86
|
+
if (!mount || mount.mountPoint === "/" || mount.fsType === "tmpfs")
|
|
87
|
+
return "ephemeral";
|
|
88
|
+
if (ANONYMOUS_VOLUME.test(mount.root)) return "anonymous";
|
|
89
|
+
return "persistent";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The real path `path` resolves to once created: symlinks in the part that
|
|
94
|
+
* exists are followed (a `~/.claude` symlinked onto a volume is on that
|
|
95
|
+
* volume), the part that doesn't is appended as-is.
|
|
96
|
+
*/
|
|
97
|
+
function resolveExisting(path: string): string {
|
|
98
|
+
let head = path;
|
|
99
|
+
const tail: string[] = [];
|
|
100
|
+
while (!existsSync(head)) {
|
|
101
|
+
const parent = dirname(head);
|
|
102
|
+
if (parent === head) return path;
|
|
103
|
+
tail.unshift(relative(parent, head));
|
|
104
|
+
head = parent;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return tail.length ? join(realpathSync(head), ...tail) : realpathSync(head);
|
|
108
|
+
} catch {
|
|
109
|
+
return path;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Read this process's mount table; empty when there is none (not Linux). */
|
|
114
|
+
export function readMountTable(file = "/proc/self/mountinfo"): MountEntry[] {
|
|
115
|
+
try {
|
|
116
|
+
return parseMountInfo(readFileSync(file, "utf8"));
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether `path` would survive the container being recreated. */
|
|
123
|
+
export function persistenceOf(
|
|
124
|
+
path: string,
|
|
125
|
+
mounts: readonly MountEntry[],
|
|
126
|
+
): Persistence {
|
|
127
|
+
const real = resolveExisting(path).split(sep).join("/");
|
|
128
|
+
return classifyMount(mountFor(real, mounts));
|
|
129
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot check: in a container, is every backend's session store on a
|
|
3
|
+
* volume that outlives the container?
|
|
4
|
+
*
|
|
5
|
+
* The image keeps everything under one data root (HOME=/data). A setup
|
|
6
|
+
* that maps only part of it — commonly just `~/.talon` in a NAS "custom
|
|
7
|
+
* app" — runs fine until the first image update, which silently takes
|
|
8
|
+
* every Claude/Codex/OpenCode transcript with it: the database still
|
|
9
|
+
* names those sessions, but nothing is left to resume. This check turns
|
|
10
|
+
* that into one admin alert at boot, naming each path and the fix.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { backendStoreDirs } from "../backup/sources/sessions.js";
|
|
16
|
+
import { persistenceOf, type MountEntry, type Persistence } from "./mounts.js";
|
|
17
|
+
|
|
18
|
+
export type StorageFinding = {
|
|
19
|
+
path: string;
|
|
20
|
+
/** What the operator loses with it. */
|
|
21
|
+
label: string;
|
|
22
|
+
persistence: Exclude<Persistence, "persistent">;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
26
|
+
|
|
27
|
+
/** Whether this process runs inside a container (Docker, Podman, the image). */
|
|
28
|
+
export function inContainer(
|
|
29
|
+
env: Env,
|
|
30
|
+
exists: (path: string) => boolean = existsSync,
|
|
31
|
+
): boolean {
|
|
32
|
+
return (
|
|
33
|
+
env.TALON_CONTAINER === "1" ||
|
|
34
|
+
exists("/.dockerenv") ||
|
|
35
|
+
exists("/run/.containerenv")
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const LABELS: Record<string, string> = {
|
|
40
|
+
claude: "Claude Code transcripts and sign-in",
|
|
41
|
+
codex: "Codex sessions and sign-in",
|
|
42
|
+
opencode: "OpenCode sessions",
|
|
43
|
+
kilo: "Kilo sessions",
|
|
44
|
+
agy: "Antigravity sign-in and conversations",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Every path whose loss costs the operator something, with what it holds. */
|
|
48
|
+
function storagePaths(input: {
|
|
49
|
+
talonHome: string;
|
|
50
|
+
userHome: string;
|
|
51
|
+
env: Env;
|
|
52
|
+
config: Record<string, unknown>;
|
|
53
|
+
}): { path: string; label: string }[] {
|
|
54
|
+
const paths = [
|
|
55
|
+
{
|
|
56
|
+
path: input.talonHome,
|
|
57
|
+
label: "Talon home: config, chat history, memory, keys",
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
for (const store of backendStoreDirs(
|
|
61
|
+
input.userHome,
|
|
62
|
+
input.env,
|
|
63
|
+
input.config,
|
|
64
|
+
)) {
|
|
65
|
+
paths.push({
|
|
66
|
+
path: store.path,
|
|
67
|
+
label: LABELS[store.backend] ?? `${store.backend} sessions`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// Claude Code keeps its account/onboarding state next to ~/.claude,
|
|
71
|
+
// not inside it — unless CLAUDE_CONFIG_DIR moves both.
|
|
72
|
+
if (!input.env.CLAUDE_CONFIG_DIR?.trim()) {
|
|
73
|
+
paths.push({
|
|
74
|
+
path: join(input.userHome, ".claude.json"),
|
|
75
|
+
label: "Claude Code account state",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return paths;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The stores that would not survive a container recreate. Empty when the
|
|
83
|
+
* mount table is unknown: no table, no claim either way.
|
|
84
|
+
*/
|
|
85
|
+
export function findEphemeralStores(input: {
|
|
86
|
+
talonHome: string;
|
|
87
|
+
userHome: string;
|
|
88
|
+
env: Env;
|
|
89
|
+
config: Record<string, unknown>;
|
|
90
|
+
mounts: readonly MountEntry[];
|
|
91
|
+
}): StorageFinding[] {
|
|
92
|
+
if (input.mounts.length === 0) return [];
|
|
93
|
+
const findings: StorageFinding[] = [];
|
|
94
|
+
const seen = new Set<string>();
|
|
95
|
+
for (const { path, label } of storagePaths(input)) {
|
|
96
|
+
if (seen.has(path)) continue;
|
|
97
|
+
seen.add(path);
|
|
98
|
+
const persistence = persistenceOf(path, input.mounts);
|
|
99
|
+
if (persistence !== "persistent")
|
|
100
|
+
findings.push({ path, label, persistence });
|
|
101
|
+
}
|
|
102
|
+
return findings;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const WHERE: Record<StorageFinding["persistence"], string> = {
|
|
106
|
+
ephemeral: "container filesystem, lost on every image update",
|
|
107
|
+
anonymous: "anonymous Docker volume, lost when the container is removed",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The admin alert text for a set of findings. */
|
|
111
|
+
export function describeFindings(
|
|
112
|
+
findings: readonly StorageFinding[],
|
|
113
|
+
env: Env,
|
|
114
|
+
): string {
|
|
115
|
+
const lines = findings.map(
|
|
116
|
+
(f) => `• ${f.path}: ${f.label} (${WHERE[f.persistence]})`,
|
|
117
|
+
);
|
|
118
|
+
const legacy =
|
|
119
|
+
env.TALON_LAYOUT === "legacy"
|
|
120
|
+
? "\nThis container runs the old /home/bun layout (only some paths mounted)."
|
|
121
|
+
: "";
|
|
122
|
+
return (
|
|
123
|
+
"Some of Talon's state is not on a persistent volume and will be lost " +
|
|
124
|
+
"when this container is recreated:\n" +
|
|
125
|
+
lines.join("\n") +
|
|
126
|
+
legacy +
|
|
127
|
+
"\n\nFix: mount one volume at /data and run with HOME=/data (the image " +
|
|
128
|
+
"default), after copying the paths above into it. Nothing is moved for " +
|
|
129
|
+
'you. Steps: docs/docker.md, "Upgrading from the old layout".' +
|
|
130
|
+
"\nSilence this check with TALON_STORAGE_CHECK=0."
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -180,7 +180,13 @@ function storageLines(f: ReportFormatter, status: BackupStatus): string[] {
|
|
|
180
180
|
if (policy) {
|
|
181
181
|
lines.push(
|
|
182
182
|
`${f.bold("Retention:")} newest ${policy.keepLocal} kept here, ` +
|
|
183
|
-
`${policy.keepRemote} per target
|
|
183
|
+
`${policy.keepRemote} per target` +
|
|
184
|
+
(policy.keepDaily ? ` · 1/day for ${policy.keepDaily}d` : "") +
|
|
185
|
+
(policy.keepWeekly ? ` · 1/week for ${policy.keepWeekly}w` : "") +
|
|
186
|
+
(policy.keepCheckpoints
|
|
187
|
+
? ` · ${policy.keepCheckpoints} checkpoints`
|
|
188
|
+
: "") +
|
|
189
|
+
` · pinned ones and the last verified are never pruned`,
|
|
184
190
|
);
|
|
185
191
|
lines.push(
|
|
186
192
|
policy.encrypted
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { existsSync } from "node:fs";
|
|
24
|
-
import { homedir } from "node:os";
|
|
25
24
|
import { dirname, join, resolve } from "node:path";
|
|
26
25
|
import type { DoctorCheck } from "../../core/doctor/index.js";
|
|
27
26
|
import {
|
|
@@ -41,6 +40,7 @@ import {
|
|
|
41
40
|
type ProvisionState,
|
|
42
41
|
} from "../../core/plugin/provision.js";
|
|
43
42
|
import { dirs, files } from "../../util/paths.js";
|
|
43
|
+
import { userHome } from "../../util/fs-path.js";
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* The mempalace version Talon installs and reconciles the managed venv
|
|
@@ -89,7 +89,7 @@ export interface MempalaceSection {
|
|
|
89
89
|
*/
|
|
90
90
|
export function resolveMempalacePaths(
|
|
91
91
|
section: MempalaceSection | undefined,
|
|
92
|
-
home =
|
|
92
|
+
home = userHome(),
|
|
93
93
|
): { pythonPath: string; palacePath: string } {
|
|
94
94
|
return {
|
|
95
95
|
pythonPath: resolve(
|
|
@@ -166,7 +166,7 @@ export async function provisionMempalace(
|
|
|
166
166
|
const exec = deps.exec ?? runStep;
|
|
167
167
|
const platform = deps.platform ?? process.platform;
|
|
168
168
|
const now = deps.now ?? Date.now;
|
|
169
|
-
const home = deps.home ??
|
|
169
|
+
const home = deps.home ?? userHome();
|
|
170
170
|
const pathExists = deps.pathExists ?? existsSync;
|
|
171
171
|
const target = section.version ?? MEMPALACE_PINNED_VERSION;
|
|
172
172
|
|
|
@@ -606,7 +606,7 @@ export async function inspectMempalace(
|
|
|
606
606
|
): Promise<DoctorCheck[]> {
|
|
607
607
|
const exec = deps.exec ?? runStep;
|
|
608
608
|
const pathExists = deps.pathExists ?? existsSync;
|
|
609
|
-
const home = deps.home ??
|
|
609
|
+
const home = deps.home ?? userHome();
|
|
610
610
|
const { pythonPath: python } = resolveMempalacePaths(section, home);
|
|
611
611
|
const pin = section.version ?? MEMPALACE_PINNED_VERSION;
|
|
612
612
|
const managed =
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
17
|
-
import { homedir } from "node:os";
|
|
18
17
|
import { dirname, join, resolve } from "node:path";
|
|
19
18
|
import type { DoctorCheck } from "../../core/doctor/index.js";
|
|
20
19
|
import {
|
|
@@ -34,6 +33,7 @@ import {
|
|
|
34
33
|
bundledPlaywrightVersion,
|
|
35
34
|
couplingError,
|
|
36
35
|
} from "./version-coupling.js";
|
|
36
|
+
import { userHome } from "../../util/fs-path.js";
|
|
37
37
|
|
|
38
38
|
/** Engines whose builds Playwright manages (vs system channels). */
|
|
39
39
|
const MANAGED_ENGINES = new Set(["chromium", "firefox", "webkit"]);
|
|
@@ -173,7 +173,7 @@ function browserPresent(
|
|
|
173
173
|
): boolean {
|
|
174
174
|
const root = browsersRoot(
|
|
175
175
|
deps.platform ?? process.platform,
|
|
176
|
-
deps.home ??
|
|
176
|
+
deps.home ?? userHome(),
|
|
177
177
|
deps.env ?? process.env,
|
|
178
178
|
);
|
|
179
179
|
const present = new Set((deps.listDir ?? safeListDir)(root));
|