talon-agent 3.28.1 → 3.29.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/README.md +12 -9
- package/package.json +1 -1
- package/src/bootstrap.ts +22 -5
- package/src/core/doctor.ts +41 -0
- package/src/core/plugin/builtins.ts +81 -5
- package/src/core/plugin/native-runtimes.ts +87 -0
- package/src/core/plugin/provision-journal.ts +172 -0
- package/src/core/plugin/provision.ts +276 -0
- package/src/frontend/discord/commands/admin.ts +5 -0
- package/src/frontend/telegram/commands/admin.ts +5 -0
- package/src/plugins/github/index.ts +15 -17
- package/src/plugins/github/provision.ts +172 -0
- package/src/plugins/mempalace/index.ts +17 -3
- package/src/plugins/mempalace/provision.ts +667 -0
- package/src/plugins/playwright/provision.ts +322 -0
- package/src/util/config.ts +27 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemPalace provisioner — self-installing, self-healing Python runtime.
|
|
3
|
+
*
|
|
4
|
+
* Talon owns exactly one environment: the venv at ~/.talon/mempalace-venv
|
|
5
|
+
* (the default `pythonPath`). That env is created on first boot, kept on
|
|
6
|
+
* the pinned mempalace version, healed when broken, and carried through
|
|
7
|
+
* one-time palace migrations. Any other `pythonPath` — a uv tool, pipx,
|
|
8
|
+
* conda, or hand-rolled venv — is respected as operator-managed: probed
|
|
9
|
+
* and advised on (exact upgrade command for its flavor), never mutated.
|
|
10
|
+
*
|
|
11
|
+
* Ordering guarantees, in priority order:
|
|
12
|
+
* 1. A working install keeps working. Upgrade failures (network down,
|
|
13
|
+
* PyPI outage) leave the current version serving and retry later
|
|
14
|
+
* with backoff; pip downloads everything before it touches the
|
|
15
|
+
* environment, and if a failure does land mid-mutation the pass
|
|
16
|
+
* rolls back to the previous version before reporting.
|
|
17
|
+
* 2. Version drift reconciles in the background — boot never blocks on
|
|
18
|
+
* pip when a usable install exists.
|
|
19
|
+
* 3. Destructive healing (venv --clear) only ever targets the
|
|
20
|
+
* Talon-owned venv, and only when it is already unusable.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { dirname, join, resolve } from "node:path";
|
|
26
|
+
import type { DoctorCheck } from "../../core/doctor.js";
|
|
27
|
+
import {
|
|
28
|
+
compareVersions,
|
|
29
|
+
expandHome,
|
|
30
|
+
failDetail,
|
|
31
|
+
findBasePython,
|
|
32
|
+
loadProvisionState,
|
|
33
|
+
markProvisionFailure,
|
|
34
|
+
markProvisionSuccess,
|
|
35
|
+
provisionBackoffMs,
|
|
36
|
+
runStep,
|
|
37
|
+
saveProvisionState,
|
|
38
|
+
shouldAttempt,
|
|
39
|
+
type ExecFn,
|
|
40
|
+
type ProvisionOutcome,
|
|
41
|
+
type ProvisionState,
|
|
42
|
+
} from "../../core/plugin/provision.js";
|
|
43
|
+
import { dirs, files } from "../../util/paths.js";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The mempalace version Talon installs and reconciles the managed venv
|
|
47
|
+
* to. Bump deliberately, with the canary workflow green — see
|
|
48
|
+
* .github/workflows/native-provision.yml.
|
|
49
|
+
*/
|
|
50
|
+
export const MEMPALACE_PINNED_VERSION = "3.8.0";
|
|
51
|
+
|
|
52
|
+
/** Minimum python for the managed venv (matches mempalace's supported floor). */
|
|
53
|
+
const PYTHON_MIN = { major: 3, minor: 10 };
|
|
54
|
+
|
|
55
|
+
/** One-time palace migration ledger key (mempalace >= 3.4 wing renames). */
|
|
56
|
+
const WING_MIGRATION = "wing-names";
|
|
57
|
+
/** First mempalace release that ships `migrate-wings`. */
|
|
58
|
+
const WING_MIGRATION_MIN_VERSION = "3.4.0";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One subprocess proves both facts that matter: the dist is installed
|
|
62
|
+
* (metadata resolves) and the MCP server module actually imports. The
|
|
63
|
+
* version is printed and flushed BEFORE the import: mempalace's
|
|
64
|
+
* mcp_server rebinds sys.stdout to stderr at import time to protect the
|
|
65
|
+
* JSON-RPC channel from stray prints, so anything printed after it
|
|
66
|
+
* lands on the wrong stream.
|
|
67
|
+
*/
|
|
68
|
+
const HEALTH_SNIPPET =
|
|
69
|
+
"import importlib.metadata as m, sys; print(m.version('mempalace')); sys.stdout.flush(); import mempalace.mcp_server";
|
|
70
|
+
|
|
71
|
+
const PROBE_TIMEOUT_MS = 60_000;
|
|
72
|
+
const VENV_TIMEOUT_MS = 120_000;
|
|
73
|
+
const PIP_TIMEOUT_MS = 900_000;
|
|
74
|
+
const MIGRATE_TIMEOUT_MS = 600_000;
|
|
75
|
+
|
|
76
|
+
/** The `mempalace`/`memory.mempalace` config section this module reads. */
|
|
77
|
+
export interface MempalaceSection {
|
|
78
|
+
pythonPath?: string;
|
|
79
|
+
palacePath?: string;
|
|
80
|
+
version?: string;
|
|
81
|
+
autoUpdate?: boolean;
|
|
82
|
+
autoProvision?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Single source of truth for path resolution (builtins, bootstrap's
|
|
87
|
+
* dream integration, and doctor all go through here): defaults applied,
|
|
88
|
+
* `~` expanded, made absolute.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveMempalacePaths(
|
|
91
|
+
section: MempalaceSection | undefined,
|
|
92
|
+
home = homedir(),
|
|
93
|
+
): { pythonPath: string; palacePath: string } {
|
|
94
|
+
return {
|
|
95
|
+
pythonPath: resolve(
|
|
96
|
+
expandHome(section?.pythonPath ?? files.mempalacePython, home),
|
|
97
|
+
),
|
|
98
|
+
palacePath: resolve(expandHome(section?.palacePath ?? dirs.palace, home)),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Injectable seams so unit tests can cover every OS and failure branch. */
|
|
103
|
+
export interface MempalaceProvisionDeps {
|
|
104
|
+
exec?: ExecFn;
|
|
105
|
+
platform?: NodeJS.Platform;
|
|
106
|
+
now?: () => number;
|
|
107
|
+
home?: string;
|
|
108
|
+
/** The Talon-owned interpreter path (default: files.mempalacePython). */
|
|
109
|
+
defaultManagedPython?: string;
|
|
110
|
+
statePath?: string;
|
|
111
|
+
pathExists?: (p: string) => boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface HealthProbe {
|
|
115
|
+
healthy: boolean;
|
|
116
|
+
version?: string;
|
|
117
|
+
error?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function probeHealth(exec: ExecFn, python: string): Promise<HealthProbe> {
|
|
121
|
+
const result = await exec(python, ["-c", HEALTH_SNIPPET], {
|
|
122
|
+
timeoutMs: PROBE_TIMEOUT_MS,
|
|
123
|
+
});
|
|
124
|
+
if (result.ok) {
|
|
125
|
+
const version = result.stdout.trim().split(/\s+/).pop();
|
|
126
|
+
if (version) return { healthy: true, version };
|
|
127
|
+
}
|
|
128
|
+
return { healthy: false, error: failDetail(result) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Classify an operator-managed install by its interpreter path. */
|
|
132
|
+
export function classifyExternalInstall(pythonPath: string): string {
|
|
133
|
+
const p = pythonPath.replaceAll("\\", "/").toLowerCase();
|
|
134
|
+
if (p.includes("/uv/tools/")) return "uv-tool";
|
|
135
|
+
if (p.includes("/pipx/")) return "pipx";
|
|
136
|
+
if (
|
|
137
|
+
p.includes("conda") ||
|
|
138
|
+
p.includes("miniforge") ||
|
|
139
|
+
p.includes("mambaforge")
|
|
140
|
+
) {
|
|
141
|
+
return "conda";
|
|
142
|
+
}
|
|
143
|
+
return "external";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The exact command that puts an operator-managed install on the pinned
|
|
148
|
+
* version, per flavor. Works in both directions (a newer-than-pin
|
|
149
|
+
* install reconciles down), hence the neutral name.
|
|
150
|
+
*/
|
|
151
|
+
export function reconcileHint(
|
|
152
|
+
kind: string,
|
|
153
|
+
python: string,
|
|
154
|
+
target: string,
|
|
155
|
+
): string {
|
|
156
|
+
if (kind === "uv-tool") {
|
|
157
|
+
return `uv tool install --force 'mempalace==${target}'`;
|
|
158
|
+
}
|
|
159
|
+
if (kind === "pipx") return `pipx install --force 'mempalace==${target}'`;
|
|
160
|
+
return `${python} -m pip install --upgrade 'mempalace==${target}'`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Single-flight guard: hot-reload must not stack concurrent pip runs. */
|
|
164
|
+
let reconcileInFlight = false;
|
|
165
|
+
|
|
166
|
+
export async function provisionMempalace(
|
|
167
|
+
section: MempalaceSection,
|
|
168
|
+
deps: MempalaceProvisionDeps = {},
|
|
169
|
+
): Promise<ProvisionOutcome> {
|
|
170
|
+
const exec = deps.exec ?? runStep;
|
|
171
|
+
const platform = deps.platform ?? process.platform;
|
|
172
|
+
const now = deps.now ?? Date.now;
|
|
173
|
+
const home = deps.home ?? homedir();
|
|
174
|
+
const pathExists = deps.pathExists ?? existsSync;
|
|
175
|
+
const target = section.version ?? MEMPALACE_PINNED_VERSION;
|
|
176
|
+
|
|
177
|
+
const { pythonPath: python, palacePath: palace } = resolveMempalacePaths(
|
|
178
|
+
section,
|
|
179
|
+
home,
|
|
180
|
+
);
|
|
181
|
+
const managedPython = resolve(
|
|
182
|
+
deps.defaultManagedPython ?? files.mempalacePython,
|
|
183
|
+
);
|
|
184
|
+
const managed = python === managedPython;
|
|
185
|
+
|
|
186
|
+
const probe = pathExists(python)
|
|
187
|
+
? await probeHealth(exec, python)
|
|
188
|
+
: { healthy: false, error: "interpreter not found" };
|
|
189
|
+
|
|
190
|
+
if (!managed) {
|
|
191
|
+
return externalOutcome(python, target, probe, pathExists);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Talon-owned venv from here on ──
|
|
195
|
+
const statePath =
|
|
196
|
+
deps.statePath ?? join(dirs.data, "mempalace-provision.json");
|
|
197
|
+
const state = loadProvisionState(statePath);
|
|
198
|
+
const venvDir = dirname(dirname(python));
|
|
199
|
+
|
|
200
|
+
const ctx: ReconcileContext = {
|
|
201
|
+
exec,
|
|
202
|
+
platform,
|
|
203
|
+
now,
|
|
204
|
+
python,
|
|
205
|
+
palace,
|
|
206
|
+
venvDir,
|
|
207
|
+
target,
|
|
208
|
+
statePath,
|
|
209
|
+
state,
|
|
210
|
+
pathExists,
|
|
211
|
+
previous: probe.healthy ? { version: probe.version! } : undefined,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (probe.healthy && probe.version === target) {
|
|
215
|
+
state.installedVersion = probe.version;
|
|
216
|
+
markProvisionSuccess(statePath, state, target, now());
|
|
217
|
+
const outcome: ProvisionOutcome = {
|
|
218
|
+
status: "ready",
|
|
219
|
+
version: probe.version,
|
|
220
|
+
kind: "managed-venv",
|
|
221
|
+
actions: [],
|
|
222
|
+
warnings: [],
|
|
223
|
+
};
|
|
224
|
+
await maybeMigratePalace(ctx, outcome);
|
|
225
|
+
return outcome;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// autoProvision governs creating/healing an unusable venv; autoUpdate
|
|
229
|
+
// governs reconciling a working one. Independent settings, so a
|
|
230
|
+
// healthy-but-drifted venv with autoProvision off still reaches the
|
|
231
|
+
// update path below (which never rebuilds a working install).
|
|
232
|
+
if (!probe.healthy && section.autoProvision === false) {
|
|
233
|
+
return {
|
|
234
|
+
status: "failed",
|
|
235
|
+
kind: "managed-venv",
|
|
236
|
+
actions: [],
|
|
237
|
+
warnings: [
|
|
238
|
+
`mempalace not usable (${probe.error}) and autoProvision is off — create the venv manually (see README) or re-enable autoProvision`,
|
|
239
|
+
],
|
|
240
|
+
error: probe.error,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (probe.healthy && section.autoUpdate === false) {
|
|
245
|
+
return {
|
|
246
|
+
status: "ready",
|
|
247
|
+
version: probe.version,
|
|
248
|
+
kind: "managed-venv",
|
|
249
|
+
actions: [],
|
|
250
|
+
warnings: [
|
|
251
|
+
`installed ${probe.version}, pinned ${target} — autoUpdate is off, upgrade manually when ready`,
|
|
252
|
+
],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!shouldAttempt(state, target, now())) {
|
|
257
|
+
const wait = Math.round(
|
|
258
|
+
provisionBackoffMs(state.failureCount ?? 1) / 60_000,
|
|
259
|
+
);
|
|
260
|
+
const notice = `last provision attempt failed (${state.lastError ?? "unknown"}); retrying with backoff (≤${wait}min) or at next pin change`;
|
|
261
|
+
return probe.healthy
|
|
262
|
+
? {
|
|
263
|
+
status: "degraded",
|
|
264
|
+
version: probe.version,
|
|
265
|
+
kind: "managed-venv",
|
|
266
|
+
actions: [],
|
|
267
|
+
warnings: [notice],
|
|
268
|
+
}
|
|
269
|
+
: {
|
|
270
|
+
status: "failed",
|
|
271
|
+
kind: "managed-venv",
|
|
272
|
+
actions: [],
|
|
273
|
+
warnings: [notice],
|
|
274
|
+
error: state.lastError,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (probe.healthy) {
|
|
279
|
+
// Usable install at the wrong version: boot on it now, reconcile in
|
|
280
|
+
// the background — the venv path is stable, so the next MCP spawn
|
|
281
|
+
// picks the new version up without any further coordination.
|
|
282
|
+
return {
|
|
283
|
+
status: "ready",
|
|
284
|
+
version: probe.version,
|
|
285
|
+
kind: "managed-venv",
|
|
286
|
+
actions: [],
|
|
287
|
+
warnings: [
|
|
288
|
+
`installed ${probe.version}, pinned ${target} — upgrading in the background`,
|
|
289
|
+
],
|
|
290
|
+
background: () => runReconcileSingleFlight(ctx),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Broken or absent: provisioning is the only way to a working plugin,
|
|
295
|
+
// so this path blocks (first boot pays the install once).
|
|
296
|
+
return runReconcileSingleFlight(ctx);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function externalOutcome(
|
|
300
|
+
python: string,
|
|
301
|
+
target: string,
|
|
302
|
+
probe: HealthProbe,
|
|
303
|
+
pathExists: (p: string) => boolean,
|
|
304
|
+
): ProvisionOutcome {
|
|
305
|
+
const kind = classifyExternalInstall(python);
|
|
306
|
+
const hint = reconcileHint(kind, python, target);
|
|
307
|
+
if (!pathExists(python)) {
|
|
308
|
+
return {
|
|
309
|
+
status: "failed",
|
|
310
|
+
kind,
|
|
311
|
+
actions: [],
|
|
312
|
+
warnings: [
|
|
313
|
+
`python not found at ${python} — this install is operator-managed (${kind}); fix the path or install with: ${hint}`,
|
|
314
|
+
],
|
|
315
|
+
error: "interpreter not found",
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
if (!probe.healthy) {
|
|
319
|
+
return {
|
|
320
|
+
status: "failed",
|
|
321
|
+
kind,
|
|
322
|
+
actions: [],
|
|
323
|
+
warnings: [
|
|
324
|
+
`mempalace not importable from ${python} (${probe.error}) — operator-managed (${kind}); install with: ${hint}`,
|
|
325
|
+
],
|
|
326
|
+
error: probe.error,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const warnings: string[] = [];
|
|
330
|
+
if (compareVersions(probe.version!, target) !== 0) {
|
|
331
|
+
warnings.push(
|
|
332
|
+
`installed ${probe.version}, Talon pins ${target} — operator-managed (${kind}), reconcile with: ${hint}`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
status: "ready",
|
|
337
|
+
version: probe.version,
|
|
338
|
+
kind,
|
|
339
|
+
actions: [],
|
|
340
|
+
warnings,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
interface ReconcileContext {
|
|
345
|
+
exec: ExecFn;
|
|
346
|
+
platform: NodeJS.Platform;
|
|
347
|
+
now: () => number;
|
|
348
|
+
python: string;
|
|
349
|
+
palace: string;
|
|
350
|
+
venvDir: string;
|
|
351
|
+
target: string;
|
|
352
|
+
statePath: string;
|
|
353
|
+
state: ProvisionState;
|
|
354
|
+
pathExists: (p: string) => boolean;
|
|
355
|
+
/** A working install that existed before this pass, if any. */
|
|
356
|
+
previous?: { version: string };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function runReconcileSingleFlight(
|
|
360
|
+
ctx: ReconcileContext,
|
|
361
|
+
): Promise<ProvisionOutcome> {
|
|
362
|
+
if (reconcileInFlight) {
|
|
363
|
+
return {
|
|
364
|
+
status: ctx.previous ? "degraded" : "failed",
|
|
365
|
+
version: ctx.previous?.version,
|
|
366
|
+
kind: "managed-venv",
|
|
367
|
+
actions: [],
|
|
368
|
+
warnings: ["a provisioning pass is already running — skipped"],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
reconcileInFlight = true;
|
|
372
|
+
try {
|
|
373
|
+
return await reconcile(ctx);
|
|
374
|
+
} finally {
|
|
375
|
+
reconcileInFlight = false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function reconcile(ctx: ReconcileContext): Promise<ProvisionOutcome> {
|
|
380
|
+
const { exec, platform, python, venvDir, target, pathExists } = ctx;
|
|
381
|
+
const actions: string[] = [];
|
|
382
|
+
const warnings: string[] = [];
|
|
383
|
+
let recreatedThisPass = false;
|
|
384
|
+
|
|
385
|
+
const fail = async (error: string): Promise<ProvisionOutcome> => {
|
|
386
|
+
markProvisionFailure(ctx.statePath, ctx.state, target, error, ctx.now());
|
|
387
|
+
if (ctx.previous) {
|
|
388
|
+
// Trust nothing: the upgrade may have died mid-mutation. Re-probe
|
|
389
|
+
// the venv and, if the previous install is gone, put it back before
|
|
390
|
+
// reporting — "staying on X" must be true, not assumed.
|
|
391
|
+
const kept = await ensurePrevious(ctx, ctx.previous.version);
|
|
392
|
+
if (kept) {
|
|
393
|
+
warnings.push(
|
|
394
|
+
`upgrade to ${target} failed (${error}) — staying on working ${kept.version}${kept.restored ? " (rolled back)" : ""}, will retry with backoff`,
|
|
395
|
+
);
|
|
396
|
+
return {
|
|
397
|
+
status: "degraded",
|
|
398
|
+
version: kept.version,
|
|
399
|
+
kind: "managed-venv",
|
|
400
|
+
actions,
|
|
401
|
+
warnings,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
warnings.push(
|
|
405
|
+
`upgrade to ${target} failed (${error}) and the previous ${ctx.previous.version} could not be restored — self-heals at the next provision pass`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
status: "failed",
|
|
410
|
+
kind: "managed-venv",
|
|
411
|
+
actions,
|
|
412
|
+
warnings,
|
|
413
|
+
error,
|
|
414
|
+
};
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const recreateVenv = async (): Promise<string | undefined> => {
|
|
418
|
+
const existedBefore = pathExists(venvDir);
|
|
419
|
+
const base = await findBasePython(exec, platform, PYTHON_MIN);
|
|
420
|
+
if (!base) {
|
|
421
|
+
return `no python >=${PYTHON_MIN.major}.${PYTHON_MIN.minor} found on PATH — install one, or point mempalace.pythonPath at an environment you manage`;
|
|
422
|
+
}
|
|
423
|
+
// --clear empties a broken venv in place; no recursive delete needed.
|
|
424
|
+
const created = await exec(
|
|
425
|
+
base.command,
|
|
426
|
+
[...base.args, "-m", "venv", "--clear", venvDir],
|
|
427
|
+
{ timeoutMs: VENV_TIMEOUT_MS },
|
|
428
|
+
);
|
|
429
|
+
if (!created.ok) {
|
|
430
|
+
// Debian/Ubuntu ship python3 without the venv module; the failure
|
|
431
|
+
// names ensurepip and the fix is a distro package, not pip.
|
|
432
|
+
const hint = /ensurepip|venv/i.test(created.stderr)
|
|
433
|
+
? " — on Debian/Ubuntu: sudo apt install python3-venv"
|
|
434
|
+
: "";
|
|
435
|
+
return `venv creation failed: ${failDetail(created)}${hint}`;
|
|
436
|
+
}
|
|
437
|
+
recreatedThisPass = true;
|
|
438
|
+
actions.push(
|
|
439
|
+
`${existedBefore ? "recreated" : "created"} venv at ${venvDir} (python ${base.version})`,
|
|
440
|
+
);
|
|
441
|
+
return undefined;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const pipInstall = async (forceReinstall: boolean) =>
|
|
445
|
+
exec(
|
|
446
|
+
python,
|
|
447
|
+
[
|
|
448
|
+
"-m",
|
|
449
|
+
"pip",
|
|
450
|
+
"install",
|
|
451
|
+
"--upgrade",
|
|
452
|
+
...(forceReinstall ? ["--force-reinstall"] : []),
|
|
453
|
+
`mempalace==${target}`,
|
|
454
|
+
],
|
|
455
|
+
{
|
|
456
|
+
timeoutMs: PIP_TIMEOUT_MS,
|
|
457
|
+
env: { PIP_DISABLE_PIP_VERSION_CHECK: "1" },
|
|
458
|
+
},
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
// Step 1 — ensure an interpreter exists at all.
|
|
462
|
+
if (!pathExists(python)) {
|
|
463
|
+
const err = await recreateVenv();
|
|
464
|
+
if (err) return await fail(err);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Step 2 — install/upgrade. A pass that started from a broken-but-present
|
|
468
|
+
// install force-reinstalls, which repairs half-written site-packages.
|
|
469
|
+
const brokenInstall = !ctx.previous && !recreatedThisPass;
|
|
470
|
+
let installed = await pipInstall(brokenInstall);
|
|
471
|
+
|
|
472
|
+
// Step 3 — heal ladder. First rung: pip itself may be missing/broken.
|
|
473
|
+
if (!installed.ok) {
|
|
474
|
+
await exec(python, ["-m", "ensurepip", "--upgrade"], {
|
|
475
|
+
timeoutMs: VENV_TIMEOUT_MS,
|
|
476
|
+
});
|
|
477
|
+
await exec(python, ["-m", "pip", "install", "--upgrade", "pip"], {
|
|
478
|
+
timeoutMs: VENV_TIMEOUT_MS,
|
|
479
|
+
env: { PIP_DISABLE_PIP_VERSION_CHECK: "1" },
|
|
480
|
+
});
|
|
481
|
+
installed = await pipInstall(brokenInstall);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Second rung: rebuild the whole venv — but never sacrifice a working
|
|
485
|
+
// install to do it; a failed upgrade keeps serving the old version.
|
|
486
|
+
if (!installed.ok && !recreatedThisPass && !ctx.previous) {
|
|
487
|
+
const err = await recreateVenv();
|
|
488
|
+
if (!err) installed = await pipInstall(false);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (!installed.ok) return await fail(failDetail(installed));
|
|
492
|
+
|
|
493
|
+
// Step 4 — trust nothing: verify the interpreter now serves the target.
|
|
494
|
+
const verify = await probeHealth(exec, python);
|
|
495
|
+
if (!verify.healthy) {
|
|
496
|
+
return await fail(`installed but not importable: ${verify.error}`);
|
|
497
|
+
}
|
|
498
|
+
if (verify.version !== target) {
|
|
499
|
+
return await fail(
|
|
500
|
+
`expected ${target} after install, found ${verify.version ?? "nothing"}`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
actions.push(
|
|
505
|
+
ctx.previous
|
|
506
|
+
? `upgraded mempalace ${ctx.previous.version} → ${target}`
|
|
507
|
+
: `installed mempalace ${target}`,
|
|
508
|
+
);
|
|
509
|
+
ctx.state.installedVersion = target;
|
|
510
|
+
markProvisionSuccess(ctx.statePath, ctx.state, target, ctx.now());
|
|
511
|
+
|
|
512
|
+
const outcome: ProvisionOutcome = {
|
|
513
|
+
status: "ready",
|
|
514
|
+
version: target,
|
|
515
|
+
kind: "managed-venv",
|
|
516
|
+
actions,
|
|
517
|
+
warnings,
|
|
518
|
+
};
|
|
519
|
+
await maybeMigratePalace(ctx, outcome);
|
|
520
|
+
return outcome;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* After a failed upgrade: confirm the previous install still serves, or
|
|
525
|
+
* reinstall it. Returns what is serving now, or undefined when the venv
|
|
526
|
+
* is broken beyond a one-shot rollback (the next pass's heal ladder
|
|
527
|
+
* takes over from there).
|
|
528
|
+
*/
|
|
529
|
+
async function ensurePrevious(
|
|
530
|
+
ctx: ReconcileContext,
|
|
531
|
+
previous: string,
|
|
532
|
+
): Promise<{ version: string; restored: boolean } | undefined> {
|
|
533
|
+
const still = await probeHealth(ctx.exec, ctx.python);
|
|
534
|
+
if (still.healthy) return { version: still.version!, restored: false };
|
|
535
|
+
const rollback = await ctx.exec(
|
|
536
|
+
ctx.python,
|
|
537
|
+
[
|
|
538
|
+
"-m",
|
|
539
|
+
"pip",
|
|
540
|
+
"install",
|
|
541
|
+
"--force-reinstall",
|
|
542
|
+
"--no-deps",
|
|
543
|
+
`mempalace==${previous}`,
|
|
544
|
+
],
|
|
545
|
+
{ timeoutMs: PIP_TIMEOUT_MS, env: { PIP_DISABLE_PIP_VERSION_CHECK: "1" } },
|
|
546
|
+
);
|
|
547
|
+
if (!rollback.ok) return undefined;
|
|
548
|
+
const after = await probeHealth(ctx.exec, ctx.python);
|
|
549
|
+
return after.healthy
|
|
550
|
+
? { version: after.version!, restored: true }
|
|
551
|
+
: undefined;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* One-time palace data migrations, applied only to palaces that already
|
|
556
|
+
* hold data (a chroma store) and recorded in the state ledger so they
|
|
557
|
+
* run exactly once per deployment. `migrate-wings` itself is idempotent,
|
|
558
|
+
* so a lost ledger costs one harmless re-run, never data. Gated on the
|
|
559
|
+
* serving version actually shipping the command — a pin below 3.4 has
|
|
560
|
+
* nothing to migrate to.
|
|
561
|
+
*/
|
|
562
|
+
async function maybeMigratePalace(
|
|
563
|
+
ctx: ReconcileContext,
|
|
564
|
+
outcome: ProvisionOutcome,
|
|
565
|
+
): Promise<void> {
|
|
566
|
+
if (ctx.state.migrations?.[WING_MIGRATION]) return;
|
|
567
|
+
if (
|
|
568
|
+
compareVersions(outcome.version ?? ctx.target, WING_MIGRATION_MIN_VERSION) <
|
|
569
|
+
0
|
|
570
|
+
) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (!ctx.pathExists(join(ctx.palace, "chroma.sqlite3"))) return;
|
|
574
|
+
|
|
575
|
+
const result = await ctx.exec(
|
|
576
|
+
ctx.python,
|
|
577
|
+
["-m", "mempalace", "--palace", ctx.palace, "migrate-wings", "--yes"],
|
|
578
|
+
{ timeoutMs: MIGRATE_TIMEOUT_MS },
|
|
579
|
+
);
|
|
580
|
+
if (result.ok) {
|
|
581
|
+
ctx.state.migrations = {
|
|
582
|
+
...ctx.state.migrations,
|
|
583
|
+
[WING_MIGRATION]: new Date(ctx.now()).toISOString(),
|
|
584
|
+
};
|
|
585
|
+
saveProvisionState(ctx.statePath, ctx.state);
|
|
586
|
+
const summary =
|
|
587
|
+
result.stdout
|
|
588
|
+
.split("\n")
|
|
589
|
+
.map((l) => l.trim())
|
|
590
|
+
.filter(
|
|
591
|
+
(l) => l.startsWith("Migrated") || l.includes("nothing to migrate"),
|
|
592
|
+
)
|
|
593
|
+
.pop() ?? "done";
|
|
594
|
+
outcome.actions.push(`palace wing-name migration: ${summary}`);
|
|
595
|
+
} else {
|
|
596
|
+
outcome.warnings.push(
|
|
597
|
+
`palace wing-name migration failed (${failDetail(result)}) — data untouched, will retry next provision pass`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Read-only doctor inspection: version vs pin, flavor, importability.
|
|
604
|
+
* Never mutates — a drifted managed venv reports "reconciles at next
|
|
605
|
+
* talon start", which is the provisioner's job, not doctor's.
|
|
606
|
+
*/
|
|
607
|
+
export async function inspectMempalace(
|
|
608
|
+
section: MempalaceSection,
|
|
609
|
+
deps: MempalaceProvisionDeps = {},
|
|
610
|
+
): Promise<DoctorCheck[]> {
|
|
611
|
+
const exec = deps.exec ?? runStep;
|
|
612
|
+
const pathExists = deps.pathExists ?? existsSync;
|
|
613
|
+
const home = deps.home ?? homedir();
|
|
614
|
+
const { pythonPath: python } = resolveMempalacePaths(section, home);
|
|
615
|
+
const pin = section.version ?? MEMPALACE_PINNED_VERSION;
|
|
616
|
+
const managed =
|
|
617
|
+
python === resolve(deps.defaultManagedPython ?? files.mempalacePython);
|
|
618
|
+
const flavor = managed ? "managed venv" : classifyExternalInstall(python);
|
|
619
|
+
const hint = reconcileHint(flavor, python, pin);
|
|
620
|
+
const provisionOff = section.autoProvision === false;
|
|
621
|
+
const updateOff = section.autoUpdate === false;
|
|
622
|
+
|
|
623
|
+
if (!pathExists(python)) {
|
|
624
|
+
return [
|
|
625
|
+
{
|
|
626
|
+
label: `MemPalace runtime missing (${flavor})`,
|
|
627
|
+
status: managed && !provisionOff ? "warn" : "fail",
|
|
628
|
+
detail: managed
|
|
629
|
+
? provisionOff
|
|
630
|
+
? `automatic provisioning disabled (mempalace.autoProvision: false) — create the venv manually (see README) or re-enable it`
|
|
631
|
+
: `provisions automatically at next talon start (pin ${pin})`
|
|
632
|
+
: `python not found at ${python} — fix the path or install with: ${hint}`,
|
|
633
|
+
issue: true,
|
|
634
|
+
},
|
|
635
|
+
];
|
|
636
|
+
}
|
|
637
|
+
const probe = await probeHealth(exec, python);
|
|
638
|
+
if (!probe.healthy) {
|
|
639
|
+
return [
|
|
640
|
+
{
|
|
641
|
+
label: `MemPalace broken (${flavor})`,
|
|
642
|
+
status: managed && !provisionOff ? "warn" : "fail",
|
|
643
|
+
detail: managed
|
|
644
|
+
? provisionOff
|
|
645
|
+
? `not importable (${probe.error}); automatic healing disabled (mempalace.autoProvision: false) — repair manually or re-enable it`
|
|
646
|
+
: "self-heals at next talon start"
|
|
647
|
+
: `mempalace not importable from ${python} (${probe.error}) — install with: ${hint}`,
|
|
648
|
+
issue: true,
|
|
649
|
+
},
|
|
650
|
+
];
|
|
651
|
+
}
|
|
652
|
+
if (probe.version === pin) {
|
|
653
|
+
return [{ label: `MemPalace ${probe.version} (${flavor})`, status: "ok" }];
|
|
654
|
+
}
|
|
655
|
+
return [
|
|
656
|
+
{
|
|
657
|
+
label: `MemPalace ${probe.version}, pinned ${pin} (${flavor})`,
|
|
658
|
+
status: "warn",
|
|
659
|
+
detail: managed
|
|
660
|
+
? updateOff
|
|
661
|
+
? `automatic update disabled (mempalace.autoUpdate: false) — run: ${hint}`
|
|
662
|
+
: "reconciles at next talon start"
|
|
663
|
+
: `operator-managed install — reconcile when ready with: ${hint}`,
|
|
664
|
+
issue: managed,
|
|
665
|
+
},
|
|
666
|
+
];
|
|
667
|
+
}
|