cueloop 0.1.0-alpha.72 → 0.1.0-alpha.73
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 +5 -5
- package/src/daemon-control.test.ts +43 -0
- package/src/daemon-control.ts +108 -0
- package/src/main.ts +4 -0
- package/src/update-command.test.ts +44 -4
- package/src/update-command.ts +19 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cueloop",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.73",
|
|
4
4
|
"description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -30,10 +30,10 @@
|
|
|
30
30
|
"build:binary": "bun build --compile --external cpu-features ./src/main.ts --outfile dist/cueloop"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@cueloop/adapters": "0.1.0-alpha.
|
|
34
|
-
"@cueloop/client": "0.1.0-alpha.
|
|
35
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
36
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
33
|
+
"@cueloop/adapters": "0.1.0-alpha.73",
|
|
34
|
+
"@cueloop/client": "0.1.0-alpha.73",
|
|
35
|
+
"@cueloop/daemon": "0.1.0-alpha.73",
|
|
36
|
+
"@cueloop/schema": "0.1.0-alpha.73",
|
|
37
37
|
"valibot": "^1.4.2"
|
|
38
38
|
},
|
|
39
39
|
"engines": {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cueloop stop`'s primitive against a real detached daemon: stopping it clears
|
|
3
|
+
* the socket and pid so the next launch autostarts a fresh one (the self-heal
|
|
4
|
+
* `cueloop update` relies on), and stopping when none runs is a no-op.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
8
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
12
|
+
import { pidPath, socketPath } from "@cueloop/daemon/paths";
|
|
13
|
+
import { stopDaemon } from "./daemon-control";
|
|
14
|
+
|
|
15
|
+
let home: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-stop-"));
|
|
19
|
+
});
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await stopDaemon(home).catch(() => undefined);
|
|
22
|
+
rmSync(home, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("stopDaemon stops a running daemon and clears its socket and pid", async () => {
|
|
26
|
+
// Arrange - a real detached daemon owns the home
|
|
27
|
+
const client = await DaemonClient.connect({ home, autostart: true });
|
|
28
|
+
|
|
29
|
+
client.close();
|
|
30
|
+
expect(existsSync(socketPath(home))).toBe(true);
|
|
31
|
+
|
|
32
|
+
// Act
|
|
33
|
+
const stopped = await stopDaemon(home);
|
|
34
|
+
|
|
35
|
+
// Assert - it reported a stop, and the daemon released its socket and pid
|
|
36
|
+
expect(stopped).toBe(true);
|
|
37
|
+
expect(existsSync(socketPath(home))).toBe(false);
|
|
38
|
+
expect(existsSync(pidPath(home))).toBe(false);
|
|
39
|
+
}, 60_000);
|
|
40
|
+
|
|
41
|
+
test("stopDaemon reports nothing to stop when no daemon is running", async () => {
|
|
42
|
+
expect(await stopDaemon(home)).toBe(false);
|
|
43
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local daemon control: `cueloop stop` and `cueloop restart`, and the
|
|
3
|
+
* `stopDaemon` primitive `cueloop update` calls so a newer binary is not left
|
|
4
|
+
* talking to the old-version daemon (which refuses the handshake).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
8
|
+
import * as v from "valibot";
|
|
9
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
10
|
+
import { cueloopHome, pidPath, socketPath } from "@cueloop/daemon/paths";
|
|
11
|
+
|
|
12
|
+
const ProcessIdSchema = v.pipe(
|
|
13
|
+
v.string(),
|
|
14
|
+
v.transform((raw) => Number(raw.trim())),
|
|
15
|
+
v.integer(),
|
|
16
|
+
v.minValue(1),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
/** Signal the daemon through its pid file; callers only reach this while a socket proves it is live. */
|
|
20
|
+
function signalDaemonByProcessId(home: string): boolean {
|
|
21
|
+
const path = pidPath(home);
|
|
22
|
+
|
|
23
|
+
if (!existsSync(path)) return false;
|
|
24
|
+
let raw: string;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
raw = readFileSync(path, "utf8");
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const parsed = v.safeParse(ProcessIdSchema, raw);
|
|
32
|
+
|
|
33
|
+
if (!parsed.success) return false;
|
|
34
|
+
try {
|
|
35
|
+
process.kill(parsed.output);
|
|
36
|
+
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True once the stopped daemon has released its socket; false if it is still there at the deadline. */
|
|
44
|
+
async function waitForSocketGone(home: string): Promise<boolean> {
|
|
45
|
+
const path = socketPath(home);
|
|
46
|
+
const deadline = Date.now() + 5_000;
|
|
47
|
+
|
|
48
|
+
while (Date.now() < deadline) {
|
|
49
|
+
if (!existsSync(path)) return true;
|
|
50
|
+
await Bun.sleep(50);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return !existsSync(path);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stop the daemon that owns `home`, returning true once its socket is released.
|
|
58
|
+
* A live daemon is stopped by the owner-gated shutdown request, or, when it
|
|
59
|
+
* refuses the handshake (a version mismatch), by signalling its pid - safe
|
|
60
|
+
* because the socket proves the pid file is current. A home with no socket has
|
|
61
|
+
* no live daemon to stop, so its pid file is never trusted.
|
|
62
|
+
*/
|
|
63
|
+
export async function stopDaemon(home = cueloopHome()): Promise<boolean> {
|
|
64
|
+
if (!existsSync(socketPath(home))) return false;
|
|
65
|
+
try {
|
|
66
|
+
const client = await DaemonClient.connect({ home, autostart: false });
|
|
67
|
+
|
|
68
|
+
await client.shutdown();
|
|
69
|
+
client.close();
|
|
70
|
+
} catch {
|
|
71
|
+
if (!signalDaemonByProcessId(home)) return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return waitForSocketGone(home);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `cueloop stop` - stop the local daemon. */
|
|
78
|
+
export async function stopCommand(): Promise<number> {
|
|
79
|
+
const home = cueloopHome();
|
|
80
|
+
|
|
81
|
+
if (!existsSync(socketPath(home))) {
|
|
82
|
+
console.log("no cueloop daemon is running");
|
|
83
|
+
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
const stopped = await stopDaemon(home);
|
|
87
|
+
|
|
88
|
+
console.log(stopped ? "cueloop daemon stopped" : "cueloop daemon did not stop in time");
|
|
89
|
+
|
|
90
|
+
return stopped ? 0 : 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `cueloop restart` - stop the local daemon, then autostart a fresh one. */
|
|
94
|
+
export async function restartCommand(): Promise<number> {
|
|
95
|
+
const home = cueloopHome();
|
|
96
|
+
|
|
97
|
+
await stopDaemon(home);
|
|
98
|
+
// a wedged daemon that never released its socket would otherwise be reused, not replaced
|
|
99
|
+
const socket = socketPath(home);
|
|
100
|
+
|
|
101
|
+
if (existsSync(socket)) rmSync(socket, { force: true });
|
|
102
|
+
const client = await DaemonClient.connect({ home, autostart: true });
|
|
103
|
+
|
|
104
|
+
client.close();
|
|
105
|
+
console.log("cueloop daemon restarted");
|
|
106
|
+
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -105,6 +105,8 @@ interface CommandHandlers {
|
|
|
105
105
|
const commandHandlers: CommandHandlers = {
|
|
106
106
|
session: (rest) => sessionCommand(rest),
|
|
107
107
|
daemon: (rest) => daemonCommand(rest),
|
|
108
|
+
stop: async () => (await import("./daemon-control")).stopCommand(),
|
|
109
|
+
restart: async () => (await import("./daemon-control")).restartCommand(),
|
|
108
110
|
plan: (rest) => planCommand(rest),
|
|
109
111
|
reply: (rest) => replyCommand(rest),
|
|
110
112
|
diff: (rest) => diffCommand(rest),
|
|
@@ -354,6 +356,8 @@ function printHelp(): void {
|
|
|
354
356
|
" cueloop wake <id> [--harness codex --thread <id>] resume the agent with the verdict (spawn detached)",
|
|
355
357
|
" cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
|
|
356
358
|
" cueloop daemon run the daemon in the foreground",
|
|
359
|
+
" cueloop stop stop the local daemon",
|
|
360
|
+
" cueloop restart stop the local daemon and start a fresh one",
|
|
357
361
|
" cueloop dev open the TUI on an isolated home seeded with example threads",
|
|
358
362
|
"",
|
|
359
363
|
" cueloop -v, --version print the installed version",
|
|
@@ -3,16 +3,22 @@
|
|
|
3
3
|
import { describe, expect, mock, test } from "bun:test";
|
|
4
4
|
import { resolveInstallDir, runUpdate, type UpdateDeps } from "./update-command";
|
|
5
5
|
|
|
6
|
-
function depsSpy(
|
|
6
|
+
function depsSpy(
|
|
7
|
+
overrides: Partial<UpdateDeps> = {},
|
|
8
|
+
): UpdateDeps & { lines: string[]; errorLines: string[] } {
|
|
7
9
|
const lines: string[] = [];
|
|
10
|
+
const errorLines: string[] = [];
|
|
8
11
|
|
|
9
12
|
return {
|
|
10
13
|
currentVersion: "0.1.0-alpha.68",
|
|
11
14
|
installDir: () => "/Users/dev/.local/bin",
|
|
12
15
|
fetchLatestVersion: mock(async () => "0.1.0-alpha.68"),
|
|
13
16
|
runInstaller: mock(async () => 0),
|
|
17
|
+
stopDaemon: mock(async () => true),
|
|
14
18
|
out: (message: string) => void lines.push(message),
|
|
19
|
+
error: (message: string) => void errorLines.push(message),
|
|
15
20
|
lines,
|
|
21
|
+
errorLines,
|
|
16
22
|
...overrides,
|
|
17
23
|
};
|
|
18
24
|
}
|
|
@@ -116,9 +122,27 @@ describe(runUpdate, () => {
|
|
|
116
122
|
// Act
|
|
117
123
|
const code = await runUpdate(deps, false);
|
|
118
124
|
|
|
119
|
-
// Assert
|
|
125
|
+
// Assert - it also stops the stale-version daemon so the next launch autostarts the new build
|
|
120
126
|
expect(code).toBe(0);
|
|
121
127
|
expect(deps.runInstaller).toHaveBeenCalledWith("/Users/dev/.local/bin");
|
|
128
|
+
expect(deps.stopDaemon).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(deps.lines).toContain("Update ran successfully! Please restart cueloop.");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("a failing daemon stop never fails an install that already succeeded", async () => {
|
|
133
|
+
// Arrange - the install succeeds but the cleanup stop rejects
|
|
134
|
+
const deps = depsSpy({
|
|
135
|
+
fetchLatestVersion: mock(async () => "0.1.0-alpha.99"),
|
|
136
|
+
stopDaemon: mock(async () => {
|
|
137
|
+
throw new Error("pid file vanished");
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Act
|
|
142
|
+
const code = await runUpdate(deps, false);
|
|
143
|
+
|
|
144
|
+
// Assert - the update still reports success
|
|
145
|
+
expect(code).toBe(0);
|
|
122
146
|
expect(deps.lines).toContain("Update ran successfully! Please restart cueloop.");
|
|
123
147
|
});
|
|
124
148
|
|
|
@@ -143,8 +167,9 @@ describe(runUpdate, () => {
|
|
|
143
167
|
// Act
|
|
144
168
|
const code = await runUpdate(deps, false);
|
|
145
169
|
|
|
146
|
-
// Assert
|
|
170
|
+
// Assert - a failed install leaves the current daemon alone
|
|
147
171
|
expect(code).toBe(1);
|
|
172
|
+
expect(deps.stopDaemon).not.toHaveBeenCalled();
|
|
148
173
|
expect(deps.lines).not.toContain("Update ran successfully! Please restart cueloop.");
|
|
149
174
|
});
|
|
150
175
|
|
|
@@ -155,9 +180,24 @@ describe(runUpdate, () => {
|
|
|
155
180
|
// Act
|
|
156
181
|
const code = await runUpdate(deps, false);
|
|
157
182
|
|
|
183
|
+
// Assert - the failure goes to stderr, and no progress prints to stdout
|
|
184
|
+
expect(code).toBe(1);
|
|
185
|
+
expect(deps.errorLines[0]).toContain("must be an absolute path");
|
|
186
|
+
expect(deps.lines).toHaveLength(0);
|
|
187
|
+
expect(deps.runInstaller).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("reports an unresolvable install dir on stderr with no stdout progress", async () => {
|
|
191
|
+
// Arrange - neither an override, the binary path, nor HOME resolves a target
|
|
192
|
+
const deps = depsSpy({ installDir: () => undefined });
|
|
193
|
+
|
|
194
|
+
// Act
|
|
195
|
+
const code = await runUpdate(deps, false);
|
|
196
|
+
|
|
158
197
|
// Assert
|
|
159
198
|
expect(code).toBe(1);
|
|
160
|
-
expect(deps.
|
|
199
|
+
expect(deps.errorLines[0]).toContain("HOME is not set");
|
|
200
|
+
expect(deps.lines).toHaveLength(0);
|
|
161
201
|
expect(deps.runInstaller).not.toHaveBeenCalled();
|
|
162
202
|
});
|
|
163
203
|
});
|
package/src/update-command.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
2
2
|
import * as v from "valibot";
|
|
3
3
|
import { CLI_VERSION } from "./version";
|
|
4
|
+
import { stopDaemon } from "./daemon-control";
|
|
4
5
|
|
|
5
6
|
const INSTALL_URL = "https://cueloop.dev/install.sh";
|
|
6
7
|
const RELEASES_URL = "https://api.github.com/repos/mmurakaru/cueloop/releases?per_page=100";
|
|
@@ -117,7 +118,12 @@ export interface UpdateDeps {
|
|
|
117
118
|
installDir: () => string | undefined;
|
|
118
119
|
fetchLatestVersion: () => Promise<string | undefined>;
|
|
119
120
|
runInstaller: (targetInstallDir: string) => Promise<number>;
|
|
121
|
+
/** Stop the running daemon after an update so the next launch autostarts the new build; resolves true when one was stopped. */
|
|
122
|
+
stopDaemon: () => Promise<boolean>;
|
|
123
|
+
/** Progress and status, on stdout - not an error, so it must not read as one. */
|
|
120
124
|
out: (message: string) => void;
|
|
125
|
+
/** A failure that ends the run, on stderr. */
|
|
126
|
+
error: (message: string) => void;
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
/**
|
|
@@ -130,12 +136,12 @@ export async function runUpdate(deps: UpdateDeps, dryRun: boolean): Promise<numb
|
|
|
130
136
|
const targetInstallDir = deps.installDir();
|
|
131
137
|
|
|
132
138
|
if (targetInstallDir === undefined) {
|
|
133
|
-
deps.
|
|
139
|
+
deps.error("cueloop update: HOME is not set and the current binary path is unknown");
|
|
134
140
|
|
|
135
141
|
return 1;
|
|
136
142
|
}
|
|
137
143
|
if (!targetInstallDir.startsWith("/")) {
|
|
138
|
-
deps.
|
|
144
|
+
deps.error("cueloop update: CUELOOP_INSTALL_DIR must be an absolute path");
|
|
139
145
|
|
|
140
146
|
return 1;
|
|
141
147
|
}
|
|
@@ -157,7 +163,14 @@ export async function runUpdate(deps: UpdateDeps, dryRun: boolean): Promise<numb
|
|
|
157
163
|
deps.out(`updating cueloop in ${targetInstallDir}...`);
|
|
158
164
|
const exitCode = await deps.runInstaller(targetInstallDir);
|
|
159
165
|
|
|
160
|
-
if (exitCode === 0)
|
|
166
|
+
if (exitCode === 0) {
|
|
167
|
+
try {
|
|
168
|
+
await deps.stopDaemon();
|
|
169
|
+
} catch {
|
|
170
|
+
// stopping the old daemon is cleanup; it must never fail an install that already succeeded
|
|
171
|
+
}
|
|
172
|
+
deps.out("Update ran successfully! Please restart cueloop.");
|
|
173
|
+
}
|
|
161
174
|
|
|
162
175
|
return exitCode;
|
|
163
176
|
}
|
|
@@ -170,7 +183,9 @@ export async function updateCommand(argv: string[] = []): Promise<number> {
|
|
|
170
183
|
installDir: () => resolveInstallDir(process.execPath, process.env),
|
|
171
184
|
fetchLatestVersion,
|
|
172
185
|
runInstaller,
|
|
173
|
-
|
|
186
|
+
stopDaemon: () => stopDaemon(),
|
|
187
|
+
out: (message) => console.log(message),
|
|
188
|
+
error: (message) => console.error(message),
|
|
174
189
|
},
|
|
175
190
|
argv.includes("--dry-run"),
|
|
176
191
|
);
|