@aron98/load-lock 1.4.0 → 1.6.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 +61 -14
- package/dist/adapters/opencode/plugin.js +15 -1
- package/dist/adapters/opencode/tui.js +7 -2
- package/dist/cli/copies.d.ts +3 -10
- package/dist/cli/copies.js +32 -44
- package/dist/cli/hook.js +48 -4
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +68 -14
- package/dist/cli/main.js +108 -211
- package/dist/cli/service.d.ts +56 -0
- package/dist/cli/service.js +261 -0
- package/dist/cli/setup.d.ts +131 -0
- package/dist/cli/setup.js +942 -0
- package/dist/cli/update.d.ts +57 -0
- package/dist/cli/update.js +287 -0
- package/dist/core/daemon.d.ts +29 -3
- package/dist/core/daemon.js +111 -11
- package/dist/core/persist.d.ts +3 -0
- package/dist/core/persist.js +49 -10
- package/dist/core/state.d.ts +9 -0
- package/dist/core/state.js +41 -3
- package/dist/dashboard/feed.d.ts +23 -0
- package/dist/dashboard/feed.js +64 -2
- package/dist/dashboard/frame.js +2 -0
- package/dist/dashboard/view.d.ts +11 -0
- package/dist/dashboard/view.js +13 -0
- package/dist/mcp/server.d.ts +11 -3
- package/dist/mcp/server.js +38 -6
- package/dist/policy/text.d.ts +13 -3
- package/dist/policy/text.js +14 -3
- package/dist/shared/agent-guide.d.ts +3 -0
- package/dist/shared/agent-guide.js +16 -0
- package/dist/shared/client.js +16 -4
- package/dist/shared/drift.d.ts +23 -0
- package/dist/shared/drift.js +95 -0
- package/dist/shared/identity.d.ts +51 -0
- package/dist/shared/identity.js +116 -0
- package/dist/shared/installs.d.ts +39 -0
- package/dist/shared/installs.js +101 -0
- package/dist/shared/pkginfo.d.ts +25 -0
- package/dist/shared/pkginfo.js +70 -0
- package/dist/shared/protocol.d.ts +22 -0
- package/dist/shared/protocol.js +15 -0
- package/dist/shared/runtimes.d.ts +41 -0
- package/dist/shared/runtimes.js +81 -0
- package/dist/shared/version.d.ts +6 -0
- package/dist/shared/version.js +9 -1
- package/dist/test/ace-mcp.test.d.ts +1 -0
- package/dist/test/ace-mcp.test.js +157 -0
- package/dist/test/ace-opencode.test.d.ts +1 -0
- package/dist/test/ace-opencode.test.js +287 -0
- package/dist/test/check-pins.test.d.ts +1 -0
- package/dist/test/check-pins.test.js +108 -0
- package/dist/test/compat.test.d.ts +1 -0
- package/dist/test/compat.test.js +190 -0
- package/dist/test/hook.test.js +179 -1
- package/dist/test/identity.test.d.ts +1 -0
- package/dist/test/identity.test.js +155 -0
- package/dist/test/install.test.js +135 -1
- package/dist/test/launcher.test.d.ts +1 -0
- package/dist/test/launcher.test.js +222 -0
- package/dist/test/mcp.test.js +76 -7
- package/dist/test/plugin.test.d.ts +1 -0
- package/dist/test/plugin.test.js +144 -0
- package/dist/test/reconnect.test.d.ts +1 -0
- package/dist/test/reconnect.test.js +389 -0
- package/dist/test/runtimes.test.d.ts +1 -0
- package/dist/test/runtimes.test.js +282 -0
- package/dist/test/setup.test.d.ts +1 -0
- package/dist/test/setup.test.js +354 -0
- package/dist/test/update-command.test.d.ts +1 -0
- package/dist/test/update-command.test.js +679 -0
- package/dist/test/update.test.js +33 -1
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +48 -2
- package/docs/ARCHITECTURE.md +122 -7
- package/docs/COMPATIBILITY.md +156 -13
- package/docs/OPERATIONS.md +407 -21
- package/package.json +3 -2
- package/skills/load-lock/SKILL.md +62 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type SelfLocationKind = "bun-global" | "npm-global" | "opencode-cache" | "npx-cache" | "repo-checkout" | "unknown";
|
|
2
|
+
export interface SelfLocation {
|
|
3
|
+
kind: SelfLocationKind;
|
|
4
|
+
/** Package root of this copy. */
|
|
5
|
+
path: string;
|
|
6
|
+
/** Explanation shown in reports (part of the refusal reason when refusing). */
|
|
7
|
+
detail: string;
|
|
8
|
+
}
|
|
9
|
+
/** Inputs classifySelf needs; callers resolve them (tests plant values). */
|
|
10
|
+
export interface ClassifyCtx {
|
|
11
|
+
home: string;
|
|
12
|
+
/** bun global node_modules root; default <home>/.bun/install/global/node_modules. */
|
|
13
|
+
bunGlobalRoot?: string;
|
|
14
|
+
/** `npm root -g` result; null when npm could not answer. */
|
|
15
|
+
npmGlobalRoot: string | null;
|
|
16
|
+
/** opencode cache root (XDG_CACHE_HOME | platform default); null when unknown. */
|
|
17
|
+
cacheRoot: string | null;
|
|
18
|
+
/** npx cache root (<npm cache>/_npx); null/absent when unknown. */
|
|
19
|
+
npxRoot?: string | null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Classify where this copy of load-lock lives, BEFORE touching anything:
|
|
23
|
+
* - bun global (<home>/.bun/install/global/node_modules/...) → update via bun
|
|
24
|
+
* - npm global (under `npm root -g`) → update via npm
|
|
25
|
+
* - opencode cache copy (<cache>/opencode/npm/...) → refuse: opencode
|
|
26
|
+
* manages this copy and refreshes it on its own schedule
|
|
27
|
+
* - npx cache copy (<npm cache>/_npx/...) → refuse: whatever launches it
|
|
28
|
+
* via `npx @aron98/load-lock@<pin>` (skill / ACE / MCP entry) owns the pin
|
|
29
|
+
* - repo checkout (nearest .git found by walking up, like the omac
|
|
30
|
+
* read-grant logic) → refuse with a pointer to the git workflow
|
|
31
|
+
* - anything else → unknown → refuse honestly rather than guessing
|
|
32
|
+
*/
|
|
33
|
+
export declare function classifySelf(pkgRoot: string, ctx: ClassifyCtx): SelfLocation;
|
|
34
|
+
export type UpdatePlan = {
|
|
35
|
+
action: "install";
|
|
36
|
+
argv: string[];
|
|
37
|
+
detail: string;
|
|
38
|
+
} | {
|
|
39
|
+
action: "refuse";
|
|
40
|
+
reason: string;
|
|
41
|
+
};
|
|
42
|
+
/** What to run for the detected location — or why we refuse to guess. */
|
|
43
|
+
export declare function planUpdate(loc: SelfLocation): UpdatePlan;
|
|
44
|
+
export interface UpdateOptions {
|
|
45
|
+
/** --check: report only, never install or restart. */
|
|
46
|
+
check: boolean;
|
|
47
|
+
/** --json: machine-readable output (house style: humans get prose). */
|
|
48
|
+
json: boolean;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The `load-lock update` command. Prints a structured report (JSON with
|
|
52
|
+
* --json, prose otherwise) and returns the exit code:
|
|
53
|
+
* 0 up-to-date (check) or applied successfully
|
|
54
|
+
* 1 update available (--check; includes "cannot self-apply" refusals)
|
|
55
|
+
* 70 failure or refusal in apply mode
|
|
56
|
+
*/
|
|
57
|
+
export declare function updateCommand(opts: UpdateOptions): Promise<number>;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `load-lock update` (issue #38): check the npm registry for a newer
|
|
3
|
+
* version (npm is the only distribution channel), classify where THIS copy
|
|
4
|
+
* lives before touching anything, and apply the update via the detected
|
|
5
|
+
* package manager — or refuse honestly (repo checkouts, opencode-managed
|
|
6
|
+
* cache copies, npx cache copies, unknown locations) rather than guessing. After a
|
|
7
|
+
* successful apply, `service restart` moves the daemon onto the new code
|
|
8
|
+
* immediately; the v1.3.0 version handshake stays as the safety net for
|
|
9
|
+
* daemons nobody explicitly restarts.
|
|
10
|
+
*
|
|
11
|
+
* Classification and planning are pure functions of their arguments
|
|
12
|
+
* (planted-layout testable, like shared/transport.ts); the registry and
|
|
13
|
+
* package-manager spawns resolve `npm` through PATH so tests stub them
|
|
14
|
+
* with a fake `npm` on PATH — the real registry is never contacted in
|
|
15
|
+
* tests and nothing is ever installed by them.
|
|
16
|
+
*
|
|
17
|
+
* Exit codes: 0 up-to-date / applied; 1 update available (`--check`,
|
|
18
|
+
* including "an update exists but this copy cannot self-apply" refusals);
|
|
19
|
+
* 70 failure (registry unreachable, package manager failed, refused in
|
|
20
|
+
* apply mode, or restart failed after an applied update).
|
|
21
|
+
*/
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
24
|
+
import { dirname, join, sep } from "node:path";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { compareVersions, packageVersion } from "../shared/version.js";
|
|
27
|
+
import { npxCacheRoot, opencodeCacheRoot, PKG_NAME } from "../shared/pkginfo.js";
|
|
28
|
+
import { thisCopy } from "./copies.js";
|
|
29
|
+
import { serviceRestart, ServiceError } from "./service.js";
|
|
30
|
+
/** Path containment with separator awareness ("a" contains "a" and "a/b"). */
|
|
31
|
+
function isUnder(dir, root) {
|
|
32
|
+
if (dir === root)
|
|
33
|
+
return true;
|
|
34
|
+
return dir.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Classify where this copy of load-lock lives, BEFORE touching anything:
|
|
38
|
+
* - bun global (<home>/.bun/install/global/node_modules/...) → update via bun
|
|
39
|
+
* - npm global (under `npm root -g`) → update via npm
|
|
40
|
+
* - opencode cache copy (<cache>/opencode/npm/...) → refuse: opencode
|
|
41
|
+
* manages this copy and refreshes it on its own schedule
|
|
42
|
+
* - npx cache copy (<npm cache>/_npx/...) → refuse: whatever launches it
|
|
43
|
+
* via `npx @aron98/load-lock@<pin>` (skill / ACE / MCP entry) owns the pin
|
|
44
|
+
* - repo checkout (nearest .git found by walking up, like the omac
|
|
45
|
+
* read-grant logic) → refuse with a pointer to the git workflow
|
|
46
|
+
* - anything else → unknown → refuse honestly rather than guessing
|
|
47
|
+
*/
|
|
48
|
+
export function classifySelf(pkgRoot, ctx) {
|
|
49
|
+
const bunRoot = ctx.bunGlobalRoot ?? join(ctx.home, ".bun", "install", "global", "node_modules");
|
|
50
|
+
if (isUnder(pkgRoot, bunRoot)) {
|
|
51
|
+
return { kind: "bun-global", path: pkgRoot, detail: `bun global install under ${bunRoot}` };
|
|
52
|
+
}
|
|
53
|
+
if (ctx.npmGlobalRoot && isUnder(pkgRoot, ctx.npmGlobalRoot)) {
|
|
54
|
+
return { kind: "npm-global", path: pkgRoot, detail: `npm global install under ${ctx.npmGlobalRoot}` };
|
|
55
|
+
}
|
|
56
|
+
if (ctx.cacheRoot && isUnder(pkgRoot, join(ctx.cacheRoot, "opencode", "npm"))) {
|
|
57
|
+
return { kind: "opencode-cache", path: pkgRoot, detail: `opencode-managed cache copy under ${ctx.cacheRoot}` };
|
|
58
|
+
}
|
|
59
|
+
if (ctx.npxRoot && isUnder(pkgRoot, ctx.npxRoot)) {
|
|
60
|
+
return { kind: "npx-cache", path: pkgRoot, detail: `npx cache copy under ${ctx.npxRoot}` };
|
|
61
|
+
}
|
|
62
|
+
for (let dir = pkgRoot;;) {
|
|
63
|
+
if (existsSync(join(dir, ".git"))) {
|
|
64
|
+
return { kind: "repo-checkout", path: pkgRoot, detail: `git checkout (.git at ${dir})` };
|
|
65
|
+
}
|
|
66
|
+
const parent = dirname(dir);
|
|
67
|
+
if (parent === dir)
|
|
68
|
+
break;
|
|
69
|
+
dir = parent;
|
|
70
|
+
}
|
|
71
|
+
return { kind: "unknown", path: pkgRoot, detail: "not a recognized bun/npm global, opencode/npx cache, or git checkout location" };
|
|
72
|
+
}
|
|
73
|
+
/** What to run for the detected location — or why we refuse to guess. */
|
|
74
|
+
export function planUpdate(loc) {
|
|
75
|
+
switch (loc.kind) {
|
|
76
|
+
case "bun-global":
|
|
77
|
+
return { action: "install", argv: ["bun", "i", "-g", `${PKG_NAME}@latest`], detail: loc.detail };
|
|
78
|
+
case "npm-global":
|
|
79
|
+
return { action: "install", argv: ["npm", "i", "-g", `${PKG_NAME}@latest`], detail: loc.detail };
|
|
80
|
+
case "opencode-cache":
|
|
81
|
+
return {
|
|
82
|
+
action: "refuse",
|
|
83
|
+
reason: `this copy lives in opencode's npm cache (${loc.path}); opencode manages it and refreshes it on its own schedule — ` +
|
|
84
|
+
`update the PATH-installed CLI instead (npm i -g ${PKG_NAME}@latest)`,
|
|
85
|
+
};
|
|
86
|
+
case "npx-cache":
|
|
87
|
+
return {
|
|
88
|
+
action: "refuse",
|
|
89
|
+
reason: `this copy lives in the npx cache (${loc.path}); it is whatever version the launching npx command pins — ` +
|
|
90
|
+
`bump the pinned version (the \`npx -y ${PKG_NAME}@<version>\` in your MCP/skill config, or the ACE artifact) ` +
|
|
91
|
+
`to move it, and update the PATH-installed CLI separately (npm i -g ${PKG_NAME}@latest)`,
|
|
92
|
+
};
|
|
93
|
+
case "repo-checkout":
|
|
94
|
+
return {
|
|
95
|
+
action: "refuse",
|
|
96
|
+
reason: `this copy is a git checkout (${loc.path}) — update it via your git workflow (e.g. git pull), not a package manager`,
|
|
97
|
+
};
|
|
98
|
+
case "unknown":
|
|
99
|
+
return {
|
|
100
|
+
action: "refuse",
|
|
101
|
+
reason: `cannot tell how this copy (${loc.path}) was installed — refusing to guess; ` +
|
|
102
|
+
`if it is a global install, run npm i -g ${PKG_NAME}@latest (or the bun equivalent) by hand`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** One npm invocation, bounded; resolves null on timeout/spawn failure. */
|
|
107
|
+
function npmOutput(args, timeoutMs) {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
let stdout = "";
|
|
110
|
+
let stderr = "";
|
|
111
|
+
let child;
|
|
112
|
+
try {
|
|
113
|
+
child = spawn("npm", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
resolve(null);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const timer = setTimeout(() => {
|
|
120
|
+
child.kill("SIGKILL");
|
|
121
|
+
resolve(null);
|
|
122
|
+
}, timeoutMs);
|
|
123
|
+
timer.unref?.();
|
|
124
|
+
child.stdout?.on("data", (b) => (stdout += b));
|
|
125
|
+
child.stderr?.on("data", (b) => (stderr += b));
|
|
126
|
+
child.once("error", () => {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
resolve(null);
|
|
129
|
+
});
|
|
130
|
+
child.once("exit", (code) => {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
resolve({ code, stdout, stderr });
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/** Latest published version from the npm registry, or null on failure. */
|
|
137
|
+
async function viewLatest(timeoutMs) {
|
|
138
|
+
const r = await npmOutput(["view", PKG_NAME, "version"], timeoutMs);
|
|
139
|
+
if (!r || r.code !== 0)
|
|
140
|
+
return null;
|
|
141
|
+
// `npm view <pkg> version` prints the bare version (possibly quoted).
|
|
142
|
+
const line = r.stdout.trim().split("\n").pop() ?? "";
|
|
143
|
+
const v = line.replace(/^"|"$/g, "").trim();
|
|
144
|
+
return /^\d+(\.\d+)*(-.*)?$/.test(v) ? v : null;
|
|
145
|
+
}
|
|
146
|
+
/** `npm root -g` (the global node_modules dir), or null on failure. */
|
|
147
|
+
async function npmGlobalRoot(timeoutMs) {
|
|
148
|
+
const r = await npmOutput(["root", "-g"], timeoutMs);
|
|
149
|
+
if (!r || r.code !== 0)
|
|
150
|
+
return null;
|
|
151
|
+
const line = r.stdout.trim().split("\n").pop() ?? "";
|
|
152
|
+
return line || null;
|
|
153
|
+
}
|
|
154
|
+
function fail(msg, code = 70) {
|
|
155
|
+
process.stderr.write(`load-lock: ${msg}\n`);
|
|
156
|
+
process.exit(code);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The `load-lock update` command. Prints a structured report (JSON with
|
|
160
|
+
* --json, prose otherwise) and returns the exit code:
|
|
161
|
+
* 0 up-to-date (check) or applied successfully
|
|
162
|
+
* 1 update available (--check; includes "cannot self-apply" refusals)
|
|
163
|
+
* 70 failure or refusal in apply mode
|
|
164
|
+
*/
|
|
165
|
+
export async function updateCommand(opts) {
|
|
166
|
+
const timeoutMs = 30_000;
|
|
167
|
+
const out = (v, human) => {
|
|
168
|
+
if (opts.json)
|
|
169
|
+
process.stdout.write(JSON.stringify(v, null, 2) + "\n");
|
|
170
|
+
else
|
|
171
|
+
process.stdout.write(human() + "\n");
|
|
172
|
+
};
|
|
173
|
+
const locationReport = (loc) => ({ kind: loc.kind, path: loc.path });
|
|
174
|
+
// 1. Classify self BEFORE touching anything (local only, no registry).
|
|
175
|
+
const self = thisCopy();
|
|
176
|
+
const own = packageVersion();
|
|
177
|
+
if (!self) {
|
|
178
|
+
fail(`cannot resolve this copy's package root — run \`load-lock doctor\``);
|
|
179
|
+
}
|
|
180
|
+
const root = await npmGlobalRoot(timeoutMs);
|
|
181
|
+
const loc = classifySelf(self.path, {
|
|
182
|
+
home: process.env.HOME ?? homedir(),
|
|
183
|
+
npmGlobalRoot: root,
|
|
184
|
+
cacheRoot: opencodeCacheRoot(process.platform, process.env),
|
|
185
|
+
npxRoot: npxCacheRoot(process.platform, process.env),
|
|
186
|
+
});
|
|
187
|
+
// 2. Check the registry (npm is the only distribution channel).
|
|
188
|
+
const latest = await viewLatest(timeoutMs);
|
|
189
|
+
if (latest === null) {
|
|
190
|
+
fail(`could not determine the latest published version of ${PKG_NAME} (npm view failed — is the registry reachable?)`);
|
|
191
|
+
}
|
|
192
|
+
// 3. Up to date (or this copy is ahead of the registry — nothing to do).
|
|
193
|
+
if (compareVersions(latest, own) <= 0) {
|
|
194
|
+
const ahead = compareVersions(latest, own) < 0;
|
|
195
|
+
const report = {
|
|
196
|
+
state: "up-to-date",
|
|
197
|
+
current_version: own,
|
|
198
|
+
latest_version: latest,
|
|
199
|
+
location: locationReport(loc),
|
|
200
|
+
...(ahead ? { note: `this copy (v${own}) is newer than the registry latest (v${latest}) — nothing to do` } : {}),
|
|
201
|
+
};
|
|
202
|
+
out(report, () => `load-lock: up to date (v${own}; registry latest v${latest}; location: ${loc.kind})` +
|
|
203
|
+
(ahead ? ` — this copy is ahead of the registry` : ""));
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
// 4. An update exists: plan the apply for the classified location.
|
|
207
|
+
const plan = planUpdate(loc);
|
|
208
|
+
if (plan.action === "refuse") {
|
|
209
|
+
const report = {
|
|
210
|
+
state: "refused",
|
|
211
|
+
current_version: own,
|
|
212
|
+
latest_version: latest,
|
|
213
|
+
location: locationReport(loc),
|
|
214
|
+
reason: plan.reason,
|
|
215
|
+
next_action: "resolve the location issue, then run `load-lock update` again (see docs/OPERATIONS.md)",
|
|
216
|
+
};
|
|
217
|
+
out(report, () => `load-lock: update to v${latest} available, but refused: ${plan.reason}`);
|
|
218
|
+
if (opts.check)
|
|
219
|
+
return 1; // an update exists — this copy just cannot self-apply it
|
|
220
|
+
process.stderr.write(`load-lock: update refused — ${plan.reason}\n`);
|
|
221
|
+
return 70;
|
|
222
|
+
}
|
|
223
|
+
// 5. --check: report what WOULD happen; touch nothing.
|
|
224
|
+
if (opts.check) {
|
|
225
|
+
const report = {
|
|
226
|
+
state: "update-available",
|
|
227
|
+
current_version: own,
|
|
228
|
+
latest_version: latest,
|
|
229
|
+
location: locationReport(loc),
|
|
230
|
+
command: plan.argv,
|
|
231
|
+
next_action: "run `load-lock update` to apply it; the service restarts automatically afterwards",
|
|
232
|
+
};
|
|
233
|
+
out(report, () => `load-lock: update available: v${own} -> v${latest} (location: ${loc.kind}; would run: ${plan.argv.join(" ")})`);
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
// 6. Apply: run the detected package manager (output shown live).
|
|
237
|
+
process.stdout.write(opts.json
|
|
238
|
+
? JSON.stringify({ state: "applying", current_version: own, latest_version: latest, command: plan.argv }, null, 2) + "\n"
|
|
239
|
+
: `load-lock: updating v${own} -> v${latest} via ${plan.argv.join(" ")}\n`);
|
|
240
|
+
const code = await new Promise((resolve) => {
|
|
241
|
+
let child;
|
|
242
|
+
try {
|
|
243
|
+
child = spawn(plan.argv[0], plan.argv.slice(1), { stdio: "inherit" });
|
|
244
|
+
}
|
|
245
|
+
catch (e) {
|
|
246
|
+
fail(`cannot run ${plan.argv[0]}: ${e.message}`);
|
|
247
|
+
}
|
|
248
|
+
child.once("error", (e) => fail(`cannot run ${plan.argv[0]}: ${e.message}`));
|
|
249
|
+
child.once("exit", (c) => resolve(c));
|
|
250
|
+
});
|
|
251
|
+
if (code !== 0) {
|
|
252
|
+
fail(`update not applied: ${plan.argv.join(" ")} exited with code ${code} — the daemon keeps running the current code`);
|
|
253
|
+
}
|
|
254
|
+
// 7. Restart the service so the daemon moves onto the new code now
|
|
255
|
+
// (the version handshake remains the safety net, not the primary means).
|
|
256
|
+
try {
|
|
257
|
+
const r = await serviceRestart();
|
|
258
|
+
const report = {
|
|
259
|
+
state: "updated",
|
|
260
|
+
from: own,
|
|
261
|
+
to: latest,
|
|
262
|
+
location: locationReport(loc),
|
|
263
|
+
command: plan.argv,
|
|
264
|
+
restarted: true,
|
|
265
|
+
restart: { stop: r.stop, start: r.start },
|
|
266
|
+
};
|
|
267
|
+
out(report, () => `load-lock: updated v${own} -> v${latest}; ${r.stop}; ${r.start}`);
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
catch (e) {
|
|
271
|
+
const reason = e instanceof ServiceError ? e.message : String(e?.message ?? e);
|
|
272
|
+
const report = {
|
|
273
|
+
state: "updated",
|
|
274
|
+
from: own,
|
|
275
|
+
to: latest,
|
|
276
|
+
location: locationReport(loc),
|
|
277
|
+
command: plan.argv,
|
|
278
|
+
restarted: false,
|
|
279
|
+
restart_error: reason,
|
|
280
|
+
next_action: "the update IS applied; the daemon still runs old code — the version handshake will self-restart it on the " +
|
|
281
|
+
"next client contact, or run `load-lock service restart` manually (see docs/OPERATIONS.md)",
|
|
282
|
+
};
|
|
283
|
+
out(report, () => `load-lock: updated v${own} -> v${latest}, but the service restart failed: ${reason}`);
|
|
284
|
+
process.stderr.write(`load-lock: ${reason}\n`);
|
|
285
|
+
return 70;
|
|
286
|
+
}
|
|
287
|
+
}
|
package/dist/core/daemon.d.ts
CHANGED
|
@@ -29,6 +29,9 @@ export declare class Daemon {
|
|
|
29
29
|
private waiters;
|
|
30
30
|
/** wait_for_completion calls blocked on tracked jobs; multiple waiters per job are fine. */
|
|
31
31
|
private completionWaiters;
|
|
32
|
+
/** hook_once keys (issue #54): in-memory only, never persisted. A key is
|
|
33
|
+
* live until expiresAt, or — when held — until its socket closes. */
|
|
34
|
+
private hookKeys;
|
|
32
35
|
private sentEventId;
|
|
33
36
|
private stateDirPath;
|
|
34
37
|
private socketPath;
|
|
@@ -36,6 +39,10 @@ export declare class Daemon {
|
|
|
36
39
|
private expiryTimer;
|
|
37
40
|
private restartTimer;
|
|
38
41
|
private stopped;
|
|
42
|
+
/** Known runtime copies (issue #52), mirrored to runtimes.json. */
|
|
43
|
+
private runtimes;
|
|
44
|
+
/** clientRoot hints already resolved this run: raw hint -> real path (null = invalid). */
|
|
45
|
+
private runtimeHints;
|
|
39
46
|
constructor(opts?: {
|
|
40
47
|
stateDir?: string;
|
|
41
48
|
socket?: string;
|
|
@@ -53,6 +60,13 @@ export declare class Daemon {
|
|
|
53
60
|
private onConnection;
|
|
54
61
|
private send;
|
|
55
62
|
private handleLine;
|
|
63
|
+
/**
|
|
64
|
+
* Known runtime copies (issue #52): remember a copy seen via the
|
|
65
|
+
* handshake. Deduped by real path; the version is read from the copy's
|
|
66
|
+
* own package.json. Disk writes only on a new copy, a version change, or
|
|
67
|
+
* a stale lastSeen — never per request. Best-effort: never throws.
|
|
68
|
+
*/
|
|
69
|
+
private recordRuntime;
|
|
56
70
|
/**
|
|
57
71
|
* Self-restart handshake (issue #34): if a client identifies a NEWER
|
|
58
72
|
* package version than this daemon runs, schedule a respawn so the daemon
|
|
@@ -65,9 +79,12 @@ export declare class Daemon {
|
|
|
65
79
|
/**
|
|
66
80
|
* Drain + respawn onto the updated on-disk code. Persists state and the
|
|
67
81
|
* journal FIRST, then stops (destroying all client connections — they
|
|
68
|
-
* reconnect to the fresh daemon), then spawns the replacement
|
|
69
|
-
*
|
|
70
|
-
*
|
|
82
|
+
* reconnect to the fresh daemon), then spawns the replacement and exits.
|
|
83
|
+
*
|
|
84
|
+
* The replacement runs the newer client's copy when its package root
|
|
85
|
+
* (clientRoot, issue #53) checks out — see restartEntry(). Otherwise it
|
|
86
|
+
* reruns the exact same argv, which picks up an in-place update of the
|
|
87
|
+
* daemon's own copy (e.g. `npm i -g`).
|
|
71
88
|
*/
|
|
72
89
|
private selfRestart;
|
|
73
90
|
/** Returns [result, agentIdForPendingAttachment]. */
|
|
@@ -140,3 +157,12 @@ export declare function isProcessAlive(pid: number, expectedStartTime?: string,
|
|
|
140
157
|
export declare function jobAlive(pid?: number, pgid?: number, pidStartTime?: string, platform?: NodeJS.Platform): boolean | null;
|
|
141
158
|
/** Read the Linux start time of a pid (pid-reuse-safe association). */
|
|
142
159
|
export declare function processStartTime(pid: number): string | undefined;
|
|
160
|
+
/**
|
|
161
|
+
* Daemon entry point of the newer client's copy (issue #53), or null to
|
|
162
|
+
* fall back to the same-argv respawn. The hint must name a load-lock
|
|
163
|
+
* package root whose on-disk version equals the version the client
|
|
164
|
+
* claimed, with a dist/core/daemon-main.js. The hint arrives over the
|
|
165
|
+
* same-user socket, so following it grants no privilege the client did
|
|
166
|
+
* not already have (it could run that file itself).
|
|
167
|
+
*/
|
|
168
|
+
export declare function restartEntry(clientRoot: string | undefined, clientVersion: string): string | null;
|
package/dist/core/daemon.js
CHANGED
|
@@ -25,8 +25,10 @@ import { Machine } from "./state.js";
|
|
|
25
25
|
import { loadState, saveState, loadJournal, saveJournal, socketPath as defaultSocket, stateDir as defaultStateDir } from "./persist.js";
|
|
26
26
|
import { inspectMachine } from "./sysinfo.js";
|
|
27
27
|
import { ProtocolError } from "../shared/protocol.js";
|
|
28
|
+
import { HOOK_ONCE_DEFAULT_TTL_MS, HOOK_ONCE_MAX_TTL_MS } from "../shared/protocol.js";
|
|
28
29
|
import { isWindows, probeTransport } from "../shared/transport.js";
|
|
29
|
-
import { packageVersion, compareVersions } from "../shared/version.js";
|
|
30
|
+
import { packageVersion, packageRoot, compareVersions } from "../shared/version.js";
|
|
31
|
+
import { inspectPackageRoot, loadRuntimes, mergeRuntime, saveRuntimes } from "../shared/runtimes.js";
|
|
30
32
|
/** Package version of the copy of load-lock this daemon process runs from. */
|
|
31
33
|
const DAEMON_VERSION = packageVersion();
|
|
32
34
|
/**
|
|
@@ -35,6 +37,8 @@ const DAEMON_VERSION = packageVersion();
|
|
|
35
37
|
* only one respawn per version bump.
|
|
36
38
|
*/
|
|
37
39
|
const RESTARTED_FOR_ENV = "LOAD_LOCK_RESTARTED_FOR";
|
|
40
|
+
/** Re-persist a known copy's lastSeen at most this often (issue #52). */
|
|
41
|
+
const RUNTIME_TOUCH_MS = 60_000;
|
|
38
42
|
export class Daemon {
|
|
39
43
|
machine;
|
|
40
44
|
server = null;
|
|
@@ -46,6 +50,9 @@ export class Daemon {
|
|
|
46
50
|
waiters = new Map();
|
|
47
51
|
/** wait_for_completion calls blocked on tracked jobs; multiple waiters per job are fine. */
|
|
48
52
|
completionWaiters = new Map();
|
|
53
|
+
/** hook_once keys (issue #54): in-memory only, never persisted. A key is
|
|
54
|
+
* live until expiresAt, or — when held — until its socket closes. */
|
|
55
|
+
hookKeys = new Map();
|
|
49
56
|
sentEventId;
|
|
50
57
|
stateDirPath;
|
|
51
58
|
socketPath;
|
|
@@ -53,6 +60,10 @@ export class Daemon {
|
|
|
53
60
|
expiryTimer = null;
|
|
54
61
|
restartTimer = null;
|
|
55
62
|
stopped = false;
|
|
63
|
+
/** Known runtime copies (issue #52), mirrored to runtimes.json. */
|
|
64
|
+
runtimes = [];
|
|
65
|
+
/** clientRoot hints already resolved this run: raw hint -> real path (null = invalid). */
|
|
66
|
+
runtimeHints = new Map();
|
|
56
67
|
constructor(opts = {}) {
|
|
57
68
|
this.stateDirPath = defaultStateDir(opts.stateDir);
|
|
58
69
|
this.socketPath = defaultSocket(opts.socket);
|
|
@@ -81,7 +92,17 @@ export class Daemon {
|
|
|
81
92
|
this.server.on("error", reject);
|
|
82
93
|
this.server.listen(this.socketPath, () => resolve());
|
|
83
94
|
});
|
|
84
|
-
|
|
95
|
+
const own = inspectPackageRoot(packageRoot());
|
|
96
|
+
const info = {
|
|
97
|
+
pid: process.pid,
|
|
98
|
+
startedAt: Date.now(),
|
|
99
|
+
socket: this.socketPath,
|
|
100
|
+
packageRoot: own?.path ?? packageRoot(),
|
|
101
|
+
version: DAEMON_VERSION,
|
|
102
|
+
};
|
|
103
|
+
writeFileSync(join(this.stateDirPath, "daemon.json"), JSON.stringify(info, null, 2) + "\n");
|
|
104
|
+
this.runtimes = loadRuntimes(this.stateDirPath);
|
|
105
|
+
this.recordRuntime(packageRoot());
|
|
85
106
|
process.once("SIGTERM", () => this.stop());
|
|
86
107
|
process.once("SIGINT", () => this.stop());
|
|
87
108
|
this.sweepTimer = setInterval(() => this.sweep(), 5_000);
|
|
@@ -187,6 +208,9 @@ export class Daemon {
|
|
|
187
208
|
for (const w of [...this.watchers])
|
|
188
209
|
if (w.socket === sock)
|
|
189
210
|
this.watchers.delete(w);
|
|
211
|
+
for (const [k, v] of this.hookKeys)
|
|
212
|
+
if (v.sock === sock)
|
|
213
|
+
this.hookKeys.delete(k);
|
|
190
214
|
});
|
|
191
215
|
}
|
|
192
216
|
send(sock, obj) {
|
|
@@ -204,6 +228,8 @@ export class Daemon {
|
|
|
204
228
|
}
|
|
205
229
|
// Version handshake: remember the client's version for skew detection.
|
|
206
230
|
const clientVersion = typeof msg.clientVersion === "string" ? msg.clientVersion : undefined;
|
|
231
|
+
if (typeof msg.clientRoot === "string")
|
|
232
|
+
this.recordRuntime(msg.clientRoot);
|
|
207
233
|
let result;
|
|
208
234
|
let agentId;
|
|
209
235
|
try {
|
|
@@ -222,7 +248,7 @@ export class Daemon {
|
|
|
222
248
|
}
|
|
223
249
|
// Respond first, then consider a self-restart — even on errors, a
|
|
224
250
|
// newer client means this daemon is running stale on-disk code.
|
|
225
|
-
this.maybeScheduleSelfRestart(clientVersion);
|
|
251
|
+
this.maybeScheduleSelfRestart(clientVersion, msg.clientRoot);
|
|
226
252
|
return;
|
|
227
253
|
}
|
|
228
254
|
// Tool-boundary delivery: surface this agent's outstanding offers/queue
|
|
@@ -231,9 +257,41 @@ export class Daemon {
|
|
|
231
257
|
result.pending = this.machine.pending(agentId);
|
|
232
258
|
}
|
|
233
259
|
this.send(sock, { id: msg.id, ok: true, result });
|
|
234
|
-
this.maybeScheduleSelfRestart(clientVersion);
|
|
260
|
+
this.maybeScheduleSelfRestart(clientVersion, msg.clientRoot);
|
|
235
261
|
}
|
|
236
262
|
// -------------------------------------------------------- version handshake
|
|
263
|
+
/**
|
|
264
|
+
* Known runtime copies (issue #52): remember a copy seen via the
|
|
265
|
+
* handshake. Deduped by real path; the version is read from the copy's
|
|
266
|
+
* own package.json. Disk writes only on a new copy, a version change, or
|
|
267
|
+
* a stale lastSeen — never per request. Best-effort: never throws.
|
|
268
|
+
*/
|
|
269
|
+
recordRuntime(root) {
|
|
270
|
+
try {
|
|
271
|
+
let real = this.runtimeHints.get(root);
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
if (real === undefined) {
|
|
274
|
+
real = inspectPackageRoot(root)?.path ?? null;
|
|
275
|
+
if (this.runtimeHints.size > 256)
|
|
276
|
+
this.runtimeHints.clear();
|
|
277
|
+
this.runtimeHints.set(root, real);
|
|
278
|
+
}
|
|
279
|
+
if (real === null)
|
|
280
|
+
return;
|
|
281
|
+
const known = this.runtimes.find((c) => c.path === real);
|
|
282
|
+
if (known && now - known.lastSeen < RUNTIME_TOUCH_MS)
|
|
283
|
+
return;
|
|
284
|
+
const merged = mergeRuntime(this.runtimes, real, now);
|
|
285
|
+
if (!merged)
|
|
286
|
+
return;
|
|
287
|
+
// Drop copies whose path disappeared since they were last seen.
|
|
288
|
+
this.runtimes = merged.filter((c) => c.path === real || existsSync(join(c.path, "package.json")));
|
|
289
|
+
saveRuntimes(this.stateDirPath, this.runtimes);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
/* bookkeeping only */
|
|
293
|
+
}
|
|
294
|
+
}
|
|
237
295
|
/**
|
|
238
296
|
* Self-restart handshake (issue #34): if a client identifies a NEWER
|
|
239
297
|
* package version than this daemon runs, schedule a respawn so the daemon
|
|
@@ -242,7 +300,7 @@ export class Daemon {
|
|
|
242
300
|
* entirely daemon-side and best-effort: pre-handshake clients that send
|
|
243
301
|
* no version never trigger a restart.
|
|
244
302
|
*/
|
|
245
|
-
maybeScheduleSelfRestart(clientVersion) {
|
|
303
|
+
maybeScheduleSelfRestart(clientVersion, clientRoot) {
|
|
246
304
|
if (this.stopped || this.restartTimer || !clientVersion)
|
|
247
305
|
return;
|
|
248
306
|
if (compareVersions(clientVersion, DAEMON_VERSION) <= 0)
|
|
@@ -252,18 +310,21 @@ export class Daemon {
|
|
|
252
310
|
return; // already tried
|
|
253
311
|
this.restartTimer = setTimeout(() => {
|
|
254
312
|
this.restartTimer = null;
|
|
255
|
-
this.selfRestart(clientVersion);
|
|
313
|
+
this.selfRestart(clientVersion, typeof clientRoot === "string" ? clientRoot : undefined);
|
|
256
314
|
}, 250);
|
|
257
315
|
this.restartTimer.unref?.();
|
|
258
316
|
}
|
|
259
317
|
/**
|
|
260
318
|
* Drain + respawn onto the updated on-disk code. Persists state and the
|
|
261
319
|
* journal FIRST, then stops (destroying all client connections — they
|
|
262
|
-
* reconnect to the fresh daemon), then spawns the replacement
|
|
263
|
-
*
|
|
264
|
-
*
|
|
320
|
+
* reconnect to the fresh daemon), then spawns the replacement and exits.
|
|
321
|
+
*
|
|
322
|
+
* The replacement runs the newer client's copy when its package root
|
|
323
|
+
* (clientRoot, issue #53) checks out — see restartEntry(). Otherwise it
|
|
324
|
+
* reruns the exact same argv, which picks up an in-place update of the
|
|
325
|
+
* daemon's own copy (e.g. `npm i -g`).
|
|
265
326
|
*/
|
|
266
|
-
selfRestart(clientVersion) {
|
|
327
|
+
selfRestart(clientVersion, clientRoot) {
|
|
267
328
|
if (this.stopped)
|
|
268
329
|
return;
|
|
269
330
|
// Best-effort persistence; a failed save must not block the restart.
|
|
@@ -276,7 +337,9 @@ export class Daemon {
|
|
|
276
337
|
}
|
|
277
338
|
this.stop();
|
|
278
339
|
try {
|
|
279
|
-
const
|
|
340
|
+
const entry = restartEntry(clientRoot, clientVersion);
|
|
341
|
+
const args = entry ? [entry, "--state-dir", this.stateDirPath, "--socket", this.socketPath] : process.argv.slice(1);
|
|
342
|
+
const child = spawn(process.execPath, args, {
|
|
280
343
|
detached: true,
|
|
281
344
|
stdio: "ignore",
|
|
282
345
|
cwd: process.cwd(),
|
|
@@ -406,6 +469,26 @@ export class Daemon {
|
|
|
406
469
|
const cwd = String(p.cwd);
|
|
407
470
|
return [{ offers: m.allOffers().filter((e) => e.cwd === cwd) }, undefined];
|
|
408
471
|
}
|
|
472
|
+
case "hook_once": {
|
|
473
|
+
// Duplicate hook wiring (loose + plugin, two plugins, ...) fires the
|
|
474
|
+
// same hook several times; the first caller per key acts, the rest
|
|
475
|
+
// stay silent. Keys are in-memory only and expire on their own
|
|
476
|
+
// (ttlMs) or, with hold, live exactly as long as this connection.
|
|
477
|
+
const params = p;
|
|
478
|
+
if (!params.harness || !params.sessionId || !params.event) {
|
|
479
|
+
throw new ProtocolError("invalid", "hook_once: harness, sessionId and event are required");
|
|
480
|
+
}
|
|
481
|
+
const now = Date.now();
|
|
482
|
+
for (const [k, v] of this.hookKeys)
|
|
483
|
+
if (!v.sock && v.expiresAt <= now)
|
|
484
|
+
this.hookKeys.delete(k);
|
|
485
|
+
const key = JSON.stringify([params.harness, params.sessionId, params.event, params.occurrence ?? ""]);
|
|
486
|
+
if (this.hookKeys.has(key))
|
|
487
|
+
return [{ first: false }, undefined];
|
|
488
|
+
const ttl = Math.min(Math.max(Number(params.ttlMs ?? HOOK_ONCE_DEFAULT_TTL_MS) || HOOK_ONCE_DEFAULT_TTL_MS, 1_000), HOOK_ONCE_MAX_TTL_MS);
|
|
489
|
+
this.hookKeys.set(key, params.hold ? { expiresAt: Infinity, sock } : { expiresAt: now + ttl });
|
|
490
|
+
return [{ first: true }, undefined];
|
|
491
|
+
}
|
|
409
492
|
case "admin_list":
|
|
410
493
|
return [{ holders: m.diagnose((e) => jobAlive(e.pid, e.pgid, e.pidStartTime)), latestEventId: m.latestEventId() }, undefined];
|
|
411
494
|
case "admin_release": {
|
|
@@ -863,3 +946,20 @@ export function processStartTime(pid) {
|
|
|
863
946
|
return undefined;
|
|
864
947
|
}
|
|
865
948
|
}
|
|
949
|
+
/**
|
|
950
|
+
* Daemon entry point of the newer client's copy (issue #53), or null to
|
|
951
|
+
* fall back to the same-argv respawn. The hint must name a load-lock
|
|
952
|
+
* package root whose on-disk version equals the version the client
|
|
953
|
+
* claimed, with a dist/core/daemon-main.js. The hint arrives over the
|
|
954
|
+
* same-user socket, so following it grants no privilege the client did
|
|
955
|
+
* not already have (it could run that file itself).
|
|
956
|
+
*/
|
|
957
|
+
export function restartEntry(clientRoot, clientVersion) {
|
|
958
|
+
if (!clientRoot)
|
|
959
|
+
return null;
|
|
960
|
+
const found = inspectPackageRoot(clientRoot);
|
|
961
|
+
if (!found || found.version !== clientVersion)
|
|
962
|
+
return null;
|
|
963
|
+
const entry = join(found.path, "dist", "core", "daemon-main.js");
|
|
964
|
+
return existsSync(entry) ? entry : null;
|
|
965
|
+
}
|
package/dist/core/persist.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { MachineConfig, ConfigHistoryEntry, JournalEntry } from "../shared/protocol.js";
|
|
2
2
|
export declare function stateDir(override?: string): string;
|
|
3
3
|
export declare function socketPath(override?: string): string;
|
|
4
|
+
/** Format version this code writes into config.json and journal entries. */
|
|
5
|
+
export declare const STATE_FORMAT_VERSION = 1;
|
|
4
6
|
export declare function loadState(dir: string): {
|
|
5
7
|
config: MachineConfig;
|
|
6
8
|
history: ConfigHistoryEntry[];
|
|
9
|
+
formatVersion: number;
|
|
7
10
|
};
|
|
8
11
|
export declare function saveState(dir: string, config: MachineConfig, history: ConfigHistoryEntry[]): void;
|
|
9
12
|
export declare function journalPath(dir: string): string;
|