@edspencer/paddock 0.71.0 → 0.71.2
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/packages/server/dist/chat-dto.d.ts +3 -2
- package/packages/server/dist/chat-dto.js +9 -1
- package/packages/server/dist/cli/args.d.ts +11 -3
- package/packages/server/dist/cli/args.js +20 -2
- package/packages/server/dist/cli/paddock.js +3 -3
- package/packages/server/dist/cli/service/backend.d.ts +33 -0
- package/packages/server/dist/cli/service/index.d.ts +10 -1
- package/packages/server/dist/cli/service/index.js +167 -7
- package/packages/server/dist/cli/service/launchd.d.ts +27 -4
- package/packages/server/dist/cli/service/launchd.js +88 -14
- package/packages/server/dist/cli/service/ready.d.ts +61 -0
- package/packages/server/dist/cli/service/ready.js +71 -0
- package/packages/server/dist/cli/service/systemd.d.ts +21 -2
- package/packages/server/dist/cli/service/systemd.js +58 -5
- package/packages/server/dist/discover.d.ts +37 -0
- package/packages/server/dist/discover.js +51 -0
- package/packages/server/dist/herdctl.d.ts +2 -1
- package/packages/server/dist/herdctl.js +11 -6
- package/packages/server/dist/last-activity.d.ts +34 -0
- package/packages/server/dist/last-activity.js +178 -0
- package/packages/server/dist/management-ops.js +5 -1
- package/packages/server/dist/routes/discover.js +10 -3
- package/packages/server/dist/self-mcp-types.d.ts +4 -1
- package/packages/server/dist/transcripts.js +5 -3
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/Chip-Cp32dDmz.js +2 -0
- package/packages/web/dist/assets/DiscoverPage-Cs439wTf.js +2 -0
- package/packages/web/dist/assets/DiscoverView-DoGAlUXT.js +2 -0
- package/packages/web/dist/assets/InstanceConfigPage-BDypeo_r.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-BOrDawoY.js → MarkdownRenderer-DA5CpdGI.js} +2 -2
- package/packages/web/dist/assets/{ProjectMenu-B_8NYWZr.js → ProjectMenu-DXXXtXeh.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-xFWTlADS.js → ProjectRedirect-k10uM_63.js} +1 -1
- package/packages/web/dist/assets/ProjectView-BxjcMlSO.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-BF65wTqN.js → ProjectsGrid-DrzZ7R8M.js} +1 -1
- package/packages/web/dist/assets/RootHome-rWodzOC8.js +2 -0
- package/packages/web/dist/assets/{arc-7GXE70CZ.js → arc-BKAVFR26.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CAEj_lAw.js → architectureDiagram-3BPJPVTR-UzIlxaCQ.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-wNLONvdQ.js → blockDiagram-GPEHLZMM-Eu4BV7Ea.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Cf7MUqJz.js → c4Diagram-AAUBKEIU-DR8PZaoa.js} +1 -1
- package/packages/web/dist/assets/channel-C-sodtn7.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-W18BVHtE.js → chunk-2J33WTMH-CgvIIrI1.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DDJQi9KC.js → chunk-4BX2VUAB-CyJWrZqN.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-L5afa49k.js → chunk-55IACEB6-DXEnZN1d.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-Bb0Sw8oK.js → chunk-727SXJPM-Co89QTmp.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DRrKTv1K.js → chunk-AQP2D5EJ-BRxTzfev.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-CIsdDBaT.js → chunk-FMBD7UC4-DUj18CvF.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-DN5ua8KT.js → chunk-ND2GUHAM-BQMMJobN.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-DE1WNShj.js → chunk-QZHKN3VN-COx8LZCe.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D2eBPVdq.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D2eBPVdq.js +2 -0
- package/packages/web/dist/assets/{core-DadwNpE4.js → core-DnpcT0s6.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-2FTPTDA6.js → cose-bilkent-S5V4N54A-B-4NadKI.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-eJ7MdaEX.js → dagre-BM42HDAG-CuaYJexd.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-D8MZTSkB.js → diagram-2AECGRRQ-C9_anxrA.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-DVXF3Z-H.js → diagram-5GNKFQAL-Ckf3kFlY.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-DoXfbQ0i.js → diagram-KO2AKTUF-BrpUuY5x.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CRW0fiqv.js → diagram-LMA3HP47-B9lyXZXW.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-DfhYXDqr.js → diagram-OG6HWLK6-DT7ro32m.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-HG0QTHgA.js → erDiagram-TEJ5UH35-ZZn9bHQh.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-ChVekwUy.js → flowDiagram-I6XJVG4X-BxQ82PY_.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CeA-6Z-T.js → ganttDiagram-6RSMTGT7-BxliloOH.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-ew2oouNN.js → gitGraphDiagram-PVQCEYII-BlwODdH9.js} +1 -1
- package/packages/web/dist/assets/{index-CbqaEg2j.js → index-Cx9y6c3f.js} +2 -2
- package/packages/web/dist/assets/index-bC5uCujx.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BY9NYdld.js → infoDiagram-5YYISTIA-nanSBFyG.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CgbdrGBm.js → ishikawaDiagram-YF4QCWOH-yZHr_s28.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BepLFtbU.js → journeyDiagram-JHISSGLW-BKjDqQEQ.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-JHMJYGJu.js → kanban-definition-UN3LZRKU-QusvHqc6.js} +1 -1
- package/packages/web/dist/assets/{linear-Ccw8eVgC.js → linear-4xRoD38Z.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-lHjASk7x.js → mermaid.core-CeulYvDv.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Bi5Iq2M4.js → mindmap-definition-RKZ34NQL-DSjYt2Kp.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-rZuB_P1J.js → pieDiagram-4H26LBE5-Ci0gO3CA.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BuStI37i.js → quadrantDiagram-W4KKPZXB-y2qHkaHP.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BFElQKEX.js → requirementDiagram-4Y6WPE33-D9hiWbad.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-tzdQCr91.js → sankeyDiagram-5OEKKPKP-DA3Mvoze.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-j1koqb6Q.js → sequenceDiagram-3UESZ5HK-DWACrljw.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-B-dUkvgq.js → stateDiagram-AJRCARHV-CFGEhoTb.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CLfbDXLX.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BWyq6u64.js → timeline-definition-PNZ67QCA-Cjygpd9e.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B0CRl3xN.js → vennDiagram-CIIHVFJN-zQnLaxgv.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-C45bBEPS.js → wardley-L42UT6IY-DGmt-Lo2.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DT50TWrW.js → wardleyDiagram-YWT4CUSO-Dj3vDukN.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-C8iBuFGD.js → xychartDiagram-2RQKCTM6-C0bGbwbL.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/Chip-5S5MAayn.js +0 -2
- package/packages/web/dist/assets/DiscoverPage-CEzcRO7X.js +0 -2
- package/packages/web/dist/assets/DiscoverView-CArICIRO.js +0 -2
- package/packages/web/dist/assets/InstanceConfigPage-CKjJsaGZ.js +0 -2
- package/packages/web/dist/assets/ProjectView-polknqZw.js +0 -13
- package/packages/web/dist/assets/RootHome-32k1iDh4.js +0 -2
- package/packages/web/dist/assets/Surfaces-DCm19ZAD.js +0 -2
- package/packages/web/dist/assets/channel-U0NZSTs2.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D1QjNKTc.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D1QjNKTc.js +0 -2
- package/packages/web/dist/assets/index-tvJIh4A9.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-b1ksIBrs.js +0 -2
|
@@ -51,10 +51,22 @@ function xmlEscape(s) {
|
|
|
51
51
|
/**
|
|
52
52
|
* Render the plist.
|
|
53
53
|
*
|
|
54
|
-
* `KeepAlive` is
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
54
|
+
* `KeepAlive` is unconditional `<true/>`, and that is a deliberate reversal
|
|
55
|
+
* (#872). It used to be `{ SuccessfulExit: false }` — "restart it if it exits
|
|
56
|
+
* nonzero, leave it alone if it exits cleanly" — which reads like the polite
|
|
57
|
+
* choice and is, for a service, a trap. `start.ts` handles SIGTERM by shutting
|
|
58
|
+
* down cleanly and exiting `0`, so **every** SIGTERM the OS routinely sends a
|
|
59
|
+
* LaunchAgent (sleep, logout, a stray `kill`) looked to launchd like "it meant
|
|
60
|
+
* to stop", and Paddock stayed down until the next login. A hard crash was the
|
|
61
|
+
* survivable case; the graceful stop was the terminal one.
|
|
62
|
+
*
|
|
63
|
+
* The worry that motivated the dict — that `<true/>` fights a deliberate stop —
|
|
64
|
+
* does not hold for the stop this CLI actually performs. `launchctl bootout`
|
|
65
|
+
* unloads the job from the domain, and KeepAlive only applies to a job that is
|
|
66
|
+
* still loaded, so `uninstall` still stops Paddock in one call. What `<true/>`
|
|
67
|
+
* does fight is `launchctl stop`, which is why nothing here uses it.
|
|
68
|
+
*
|
|
69
|
+
* Intent belongs in an explicit command, not in an exit code.
|
|
58
70
|
*
|
|
59
71
|
* `EnvironmentVariables` carries PATH and nothing else. It carries PATH because
|
|
60
72
|
* launchd hands a process a stub PATH that a version manager's `node`, and the
|
|
@@ -82,10 +94,7 @@ ${strings}
|
|
|
82
94
|
<key>RunAtLoad</key>
|
|
83
95
|
<true/>
|
|
84
96
|
<key>KeepAlive</key>
|
|
85
|
-
<
|
|
86
|
-
<key>SuccessfulExit</key>
|
|
87
|
-
<false/>
|
|
88
|
-
</dict>
|
|
97
|
+
<true/>
|
|
89
98
|
<key>ThrottleInterval</key>
|
|
90
99
|
<integer>10</integer>
|
|
91
100
|
<key>WorkingDirectory</key>
|
|
@@ -123,6 +132,19 @@ export function parsePlistArgv(xml) {
|
|
|
123
132
|
.replace(/</g, "<")
|
|
124
133
|
.replace(/&/g, "&"));
|
|
125
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Does an installed plist carry the unconditional `KeepAlive` from #872?
|
|
137
|
+
*
|
|
138
|
+
* Deliberately narrow: it matches `<key>KeepAlive</key>` followed by `<true/>`
|
|
139
|
+
* and nothing else. The shape it must NOT accept is the old
|
|
140
|
+
* `<dict><key>SuccessfulExit</key><false/></dict>`, which is the one that
|
|
141
|
+
* leaves a service dead after a graceful stop — so a `false` here is the signal
|
|
142
|
+
* `status` turns into "re-run install", and defaulting to `true` on anything
|
|
143
|
+
* unrecognised would silence exactly the case this exists to catch.
|
|
144
|
+
*/
|
|
145
|
+
export function plistKeepsAlive(xml) {
|
|
146
|
+
return /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(xml);
|
|
147
|
+
}
|
|
126
148
|
/**
|
|
127
149
|
* Read liveness out of `launchctl print gui/<uid>/<label>`.
|
|
128
150
|
*
|
|
@@ -139,6 +161,10 @@ export function parseLaunchctlPrint(out) {
|
|
|
139
161
|
...(pid !== null ? { pid: pid[1] } : {}),
|
|
140
162
|
};
|
|
141
163
|
}
|
|
164
|
+
/** One phrasing for "launchctl said no", so every caller reports it the same way. */
|
|
165
|
+
function launchctlFailure(verb, r) {
|
|
166
|
+
return (`launchctl ${verb} failed (exit ${String(r.status)}).\n` + `${r.stderr || r.stdout}`.trim());
|
|
167
|
+
}
|
|
142
168
|
function target() {
|
|
143
169
|
return `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`;
|
|
144
170
|
}
|
|
@@ -164,10 +190,8 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
|
164
190
|
// they are shims over this one that report success in cases where it
|
|
165
191
|
// reports the actual error.
|
|
166
192
|
const boot = run("launchctl", ["bootstrap", domain(), file]);
|
|
167
|
-
if (boot.status !== 0)
|
|
168
|
-
throw new Error(
|
|
169
|
-
`${boot.stderr || boot.stdout}`.trim());
|
|
170
|
-
}
|
|
193
|
+
if (boot.status !== 0)
|
|
194
|
+
throw new Error(launchctlFailure("bootstrap", boot));
|
|
171
195
|
// `RunAtLoad` should already have started it; `kickstart -k` makes that
|
|
172
196
|
// true rather than probable, and is the same command a later restart uses.
|
|
173
197
|
run("launchctl", ["kickstart", "-k", target()]);
|
|
@@ -179,10 +203,59 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
|
179
203
|
fs.rmSync(file, { force: true });
|
|
180
204
|
return existed;
|
|
181
205
|
},
|
|
206
|
+
/**
|
|
207
|
+
* Load the job back into the GUI domain and make sure it is running (#873).
|
|
208
|
+
*
|
|
209
|
+
* `bootstrap`, not `launchctl start`: `stop` below BOOTS OUT, so a stopped
|
|
210
|
+
* Paddock is not merely idle, it is absent from the domain — and `start` on
|
|
211
|
+
* a label launchd has never heard of fails. Bootstrapping is also what a
|
|
212
|
+
* login does, so `service start` and the next login take the same path.
|
|
213
|
+
*/
|
|
214
|
+
start() {
|
|
215
|
+
const boot = run("launchctl", ["bootstrap", domain(), file]);
|
|
216
|
+
if (boot.status !== 0)
|
|
217
|
+
throw new Error(launchctlFailure("bootstrap", boot));
|
|
218
|
+
run("launchctl", ["kickstart", target()]);
|
|
219
|
+
},
|
|
220
|
+
/**
|
|
221
|
+
* `bootout`, not `launchctl stop`.
|
|
222
|
+
*
|
|
223
|
+
* With #872's unconditional `KeepAlive`, `stop` is a request launchd
|
|
224
|
+
* immediately overrules — it stops the process and then relaunches it,
|
|
225
|
+
* which is exactly the wrestling match the old conditional KeepAlive was
|
|
226
|
+
* meant to avoid. `bootout` unloads the job, so KeepAlive no longer applies
|
|
227
|
+
* and it stays down. The plist is untouched, so `status` still reports it
|
|
228
|
+
* registered, `start` puts it back, and the next LOGIN starts it again via
|
|
229
|
+
* `RunAtLoad` — a stop is for this session, not forever. Uninstall is the
|
|
230
|
+
* one that is forever.
|
|
231
|
+
*/
|
|
232
|
+
stop() {
|
|
233
|
+
const out = run("launchctl", ["bootout", target()]);
|
|
234
|
+
// Exit 3 / "No such process" is launchd for "it was not loaded", which is
|
|
235
|
+
// the state the caller asked for. Anything else is worth surfacing.
|
|
236
|
+
if (out.status !== 0 && !/no such process/i.test(`${out.stderr}${out.stdout}`)) {
|
|
237
|
+
throw new Error(launchctlFailure("bootout", out));
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
/**
|
|
241
|
+
* Boot out, bootstrap, kick. Not `kickstart -k` alone, which needs the job
|
|
242
|
+
* to be loaded and so fails on the case `restart` most needs to handle: a
|
|
243
|
+
* service that is stopped. This sequence is identical to `install`'s minus
|
|
244
|
+
* writing the plist, which also gives `restart` the property the issue asks
|
|
245
|
+
* for — the unit is re-read on the way up, so an edited plist takes effect.
|
|
246
|
+
*/
|
|
247
|
+
restart() {
|
|
248
|
+
run("launchctl", ["bootout", target()]);
|
|
249
|
+
const boot = run("launchctl", ["bootstrap", domain(), file]);
|
|
250
|
+
if (boot.status !== 0)
|
|
251
|
+
throw new Error(launchctlFailure("bootstrap", boot));
|
|
252
|
+
run("launchctl", ["kickstart", "-k", target()]);
|
|
253
|
+
},
|
|
182
254
|
status() {
|
|
183
255
|
if (!fs.existsSync(file))
|
|
184
|
-
return { registered: false, running: false, argv: [] };
|
|
185
|
-
const
|
|
256
|
+
return { registered: false, running: false, argv: [], keepsAlive: true };
|
|
257
|
+
const xml = fs.readFileSync(file, "utf8");
|
|
258
|
+
const argv = parsePlistArgv(xml);
|
|
186
259
|
const printed = run("launchctl", ["print", target()]);
|
|
187
260
|
const parsed = printed.status === 0
|
|
188
261
|
? parseLaunchctlPrint(printed.stdout)
|
|
@@ -194,6 +267,7 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
|
194
267
|
// Strip the interpreter and script: what a reader wants from `status`
|
|
195
268
|
// is the Paddock invocation, not the absolute path to node twice.
|
|
196
269
|
argv: argv.slice(2),
|
|
270
|
+
keepsAlive: plistKeepsAlive(xml),
|
|
197
271
|
};
|
|
198
272
|
},
|
|
199
273
|
logsHint(spec) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Is it actually up?" — the bounded readiness poll the service CLI uses before
|
|
3
|
+
* it claims success (#873, #861).
|
|
4
|
+
*
|
|
5
|
+
* Every service command that starts something has the same failure mode without
|
|
6
|
+
* this: the supervisor accepts the request and returns, so the CLI prints a URL
|
|
7
|
+
* and a cheerful past tense while the server is still binding — or is already
|
|
8
|
+
* crash-looping on a port clash, which `launchctl` and `systemctl` will both
|
|
9
|
+
* happily report as "running" for the ten seconds between restarts. A message
|
|
10
|
+
* that can sit over a crash-loop and still say "running at <url>" is not a
|
|
11
|
+
* status line, it is a guess.
|
|
12
|
+
*
|
|
13
|
+
* So the check is deliberately end-to-end: an HTTP request to the URL a human
|
|
14
|
+
* would open, not a pid lookup. `/api/health` is the probe because it is in
|
|
15
|
+
* `ALWAYS_PUBLIC_PATHS` (`auth.ts`) — it answers whatever `PADDOCK_AUTH_MODE`
|
|
16
|
+
* the installed unit runs with, so this does not quietly stop working for the
|
|
17
|
+
* people who configured a credential.
|
|
18
|
+
*
|
|
19
|
+
* The result is a tri-state on purpose. "I could not confirm it" is a real and
|
|
20
|
+
* common answer — an unusual bind host, a slow first boot — and it is not the
|
|
21
|
+
* same claim as "it failed". Callers print it as a caveat, not an error, and
|
|
22
|
+
* point at the logs.
|
|
23
|
+
*/
|
|
24
|
+
/** Where the poll got to when it stopped. */
|
|
25
|
+
export type Readiness = "ready" | "timeout";
|
|
26
|
+
export interface ReadyOptions {
|
|
27
|
+
/** Base URL of the server, e.g. `http://127.0.0.1:7233` — no trailing slash. */
|
|
28
|
+
url: string;
|
|
29
|
+
/** Give up after this long. */
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
/** Gap between attempts. */
|
|
32
|
+
intervalMs?: number;
|
|
33
|
+
/** Injected for tests; defaults to the global. */
|
|
34
|
+
fetchImpl?: typeof fetch;
|
|
35
|
+
/** Injected for tests, so a timeout can be proven without waiting for one. */
|
|
36
|
+
now?: () => number;
|
|
37
|
+
sleep?: (ms: number) => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* How long to wait before saying "could not confirm".
|
|
41
|
+
*
|
|
42
|
+
* Generous rather than snappy: this budget is only ever spent on the unhappy
|
|
43
|
+
* path (a ready server answers in well under a second), and the cost of being
|
|
44
|
+
* too short is a false "did not answer" on a cold boot — the single most
|
|
45
|
+
* alarming thing this output could say about a service that is in fact fine.
|
|
46
|
+
*/
|
|
47
|
+
export declare const READY_TIMEOUT_MS = 20000;
|
|
48
|
+
/** The same budget as prose, so a message and the clock cannot drift apart. */
|
|
49
|
+
export declare const READY_TIMEOUT_LABEL = "20 seconds";
|
|
50
|
+
/**
|
|
51
|
+
* Poll `<url>/api/health` until it answers, or until `timeoutMs` elapses.
|
|
52
|
+
*
|
|
53
|
+
* Connection errors are the expected case rather than the exceptional one — a
|
|
54
|
+
* server that has not finished binding refuses the connection — so they are
|
|
55
|
+
* swallowed and retried. Only the clock ends the loop.
|
|
56
|
+
*
|
|
57
|
+
* Each request carries its own abort so a hung socket cannot outlive the budget
|
|
58
|
+
* it was given; without it a single connection that opens and never replies
|
|
59
|
+
* would park here well past `timeoutMs`.
|
|
60
|
+
*/
|
|
61
|
+
export declare function waitForReady(opts: ReadyOptions): Promise<Readiness>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Is it actually up?" — the bounded readiness poll the service CLI uses before
|
|
3
|
+
* it claims success (#873, #861).
|
|
4
|
+
*
|
|
5
|
+
* Every service command that starts something has the same failure mode without
|
|
6
|
+
* this: the supervisor accepts the request and returns, so the CLI prints a URL
|
|
7
|
+
* and a cheerful past tense while the server is still binding — or is already
|
|
8
|
+
* crash-looping on a port clash, which `launchctl` and `systemctl` will both
|
|
9
|
+
* happily report as "running" for the ten seconds between restarts. A message
|
|
10
|
+
* that can sit over a crash-loop and still say "running at <url>" is not a
|
|
11
|
+
* status line, it is a guess.
|
|
12
|
+
*
|
|
13
|
+
* So the check is deliberately end-to-end: an HTTP request to the URL a human
|
|
14
|
+
* would open, not a pid lookup. `/api/health` is the probe because it is in
|
|
15
|
+
* `ALWAYS_PUBLIC_PATHS` (`auth.ts`) — it answers whatever `PADDOCK_AUTH_MODE`
|
|
16
|
+
* the installed unit runs with, so this does not quietly stop working for the
|
|
17
|
+
* people who configured a credential.
|
|
18
|
+
*
|
|
19
|
+
* The result is a tri-state on purpose. "I could not confirm it" is a real and
|
|
20
|
+
* common answer — an unusual bind host, a slow first boot — and it is not the
|
|
21
|
+
* same claim as "it failed". Callers print it as a caveat, not an error, and
|
|
22
|
+
* point at the logs.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* How long to wait before saying "could not confirm".
|
|
26
|
+
*
|
|
27
|
+
* Generous rather than snappy: this budget is only ever spent on the unhappy
|
|
28
|
+
* path (a ready server answers in well under a second), and the cost of being
|
|
29
|
+
* too short is a false "did not answer" on a cold boot — the single most
|
|
30
|
+
* alarming thing this output could say about a service that is in fact fine.
|
|
31
|
+
*/
|
|
32
|
+
export const READY_TIMEOUT_MS = 20_000;
|
|
33
|
+
/** The same budget as prose, so a message and the clock cannot drift apart. */
|
|
34
|
+
export const READY_TIMEOUT_LABEL = `${READY_TIMEOUT_MS / 1000} seconds`;
|
|
35
|
+
const DEFAULT_INTERVAL_MS = 250;
|
|
36
|
+
/**
|
|
37
|
+
* Poll `<url>/api/health` until it answers, or until `timeoutMs` elapses.
|
|
38
|
+
*
|
|
39
|
+
* Connection errors are the expected case rather than the exceptional one — a
|
|
40
|
+
* server that has not finished binding refuses the connection — so they are
|
|
41
|
+
* swallowed and retried. Only the clock ends the loop.
|
|
42
|
+
*
|
|
43
|
+
* Each request carries its own abort so a hung socket cannot outlive the budget
|
|
44
|
+
* it was given; without it a single connection that opens and never replies
|
|
45
|
+
* would park here well past `timeoutMs`.
|
|
46
|
+
*/
|
|
47
|
+
export async function waitForReady(opts) {
|
|
48
|
+
const { url, timeoutMs = READY_TIMEOUT_MS, intervalMs = DEFAULT_INTERVAL_MS, fetchImpl = fetch, now = () => Date.now(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), } = opts;
|
|
49
|
+
const deadline = now() + timeoutMs;
|
|
50
|
+
for (;;) {
|
|
51
|
+
const remaining = deadline - now();
|
|
52
|
+
if (remaining <= 0)
|
|
53
|
+
return "timeout";
|
|
54
|
+
const abort = new AbortController();
|
|
55
|
+
const timer = setTimeout(() => abort.abort(), Math.min(remaining, timeoutMs));
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetchImpl(`${url}/api/health`, { signal: abort.signal });
|
|
58
|
+
if (res.ok)
|
|
59
|
+
return "ready";
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Refused, reset, aborted — all "not yet", all retried until the clock says stop.
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
if (deadline - now() <= 0)
|
|
68
|
+
return "timeout";
|
|
69
|
+
await sleep(intervalMs);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -31,8 +31,17 @@ export declare function unitPath(homeDir?: string, xdgConfigHome?: string): stri
|
|
|
31
31
|
/**
|
|
32
32
|
* Render the unit.
|
|
33
33
|
*
|
|
34
|
-
* `Restart=
|
|
35
|
-
*
|
|
34
|
+
* `Restart=always`, matching launchd's `KeepAlive=<true/>` (#872). It was
|
|
35
|
+
* `on-failure`, which for a service is the wrong default for the same reason it
|
|
36
|
+
* was wrong there: `start.ts` exits `0` on SIGTERM, so a logout — or anything
|
|
37
|
+
* else that signals the user manager — was read as an intended stop and Paddock
|
|
38
|
+
* stayed down.
|
|
39
|
+
*
|
|
40
|
+
* `always` does not make the unit unstoppable. systemd never restarts a unit it
|
|
41
|
+
* was itself asked to stop, so `systemctl --user stop` (and therefore
|
|
42
|
+
* `uninstall`) remains the clean, explicit way to put it down. That is the
|
|
43
|
+
* difference between an exit code, which has to guess at intent, and a command,
|
|
44
|
+
* which carries it.
|
|
36
45
|
*
|
|
37
46
|
* `WantedBy=default.target` is what makes `systemctl --user enable` mean
|
|
38
47
|
* "start at login" for a user unit; `multi-user.target` is the system-unit
|
|
@@ -43,6 +52,16 @@ export declare function unitPath(homeDir?: string, xdgConfigHome?: string): stri
|
|
|
43
52
|
* answers to "where are the logs".
|
|
44
53
|
*/
|
|
45
54
|
export declare function renderUnit(spec: ServiceSpec): string;
|
|
55
|
+
/**
|
|
56
|
+
* Does an installed unit carry `Restart=always` (#872)?
|
|
57
|
+
*
|
|
58
|
+
* The counterpart to `plistKeepsAlive`, and narrow for the same reason: the
|
|
59
|
+
* value it has to reject is the `on-failure` this file used to write, which
|
|
60
|
+
* leaves Paddock down after the graceful exit `start.ts` performs on SIGTERM.
|
|
61
|
+
* Anything unrecognised reads as "not the fixed shape", which at worst tells a
|
|
62
|
+
* hand-edited unit's owner to re-run install.
|
|
63
|
+
*/
|
|
64
|
+
export declare function unitKeepsAlive(text: string): boolean;
|
|
46
65
|
/** Read `ExecStart=` back out of an installed unit, split into an argv. */
|
|
47
66
|
export declare function parseUnitArgv(text: string): string[];
|
|
48
67
|
export declare function createSystemdBackend(run: Runner, homeDir?: string, xdgConfigHome?: string): ServiceBackend;
|
|
@@ -62,8 +62,17 @@ export function unitPath(homeDir, xdgConfigHome) {
|
|
|
62
62
|
/**
|
|
63
63
|
* Render the unit.
|
|
64
64
|
*
|
|
65
|
-
* `Restart=
|
|
66
|
-
*
|
|
65
|
+
* `Restart=always`, matching launchd's `KeepAlive=<true/>` (#872). It was
|
|
66
|
+
* `on-failure`, which for a service is the wrong default for the same reason it
|
|
67
|
+
* was wrong there: `start.ts` exits `0` on SIGTERM, so a logout — or anything
|
|
68
|
+
* else that signals the user manager — was read as an intended stop and Paddock
|
|
69
|
+
* stayed down.
|
|
70
|
+
*
|
|
71
|
+
* `always` does not make the unit unstoppable. systemd never restarts a unit it
|
|
72
|
+
* was itself asked to stop, so `systemctl --user stop` (and therefore
|
|
73
|
+
* `uninstall`) remains the clean, explicit way to put it down. That is the
|
|
74
|
+
* difference between an exit code, which has to guess at intent, and a command,
|
|
75
|
+
* which carries it.
|
|
67
76
|
*
|
|
68
77
|
* `WantedBy=default.target` is what makes `systemctl --user enable` mean
|
|
69
78
|
* "start at login" for a user unit; `multi-user.target` is the system-unit
|
|
@@ -84,13 +93,25 @@ Type=simple
|
|
|
84
93
|
ExecStart=${exec}
|
|
85
94
|
WorkingDirectory=${spec.workingDirectory}
|
|
86
95
|
Environment=PATH=${spec.pathEnv}
|
|
87
|
-
Restart=
|
|
96
|
+
Restart=always
|
|
88
97
|
RestartSec=10
|
|
89
98
|
|
|
90
99
|
[Install]
|
|
91
100
|
WantedBy=default.target
|
|
92
101
|
`;
|
|
93
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Does an installed unit carry `Restart=always` (#872)?
|
|
105
|
+
*
|
|
106
|
+
* The counterpart to `plistKeepsAlive`, and narrow for the same reason: the
|
|
107
|
+
* value it has to reject is the `on-failure` this file used to write, which
|
|
108
|
+
* leaves Paddock down after the graceful exit `start.ts` performs on SIGTERM.
|
|
109
|
+
* Anything unrecognised reads as "not the fixed shape", which at worst tells a
|
|
110
|
+
* hand-edited unit's owner to re-run install.
|
|
111
|
+
*/
|
|
112
|
+
export function unitKeepsAlive(text) {
|
|
113
|
+
return /^Restart=always\s*$/m.test(text);
|
|
114
|
+
}
|
|
94
115
|
/** Read `ExecStart=` back out of an installed unit, split into an argv. */
|
|
95
116
|
export function parseUnitArgv(text) {
|
|
96
117
|
const m = /^ExecStart=(.*)$/m.exec(text);
|
|
@@ -98,6 +119,14 @@ export function parseUnitArgv(text) {
|
|
|
98
119
|
return [];
|
|
99
120
|
return m[1].trim().split(/\s+/).filter(Boolean);
|
|
100
121
|
}
|
|
122
|
+
/** Run a `systemctl` and turn a non-zero exit into the manager's own words. */
|
|
123
|
+
function systemctlOrThrow(run, args) {
|
|
124
|
+
const r = run("systemctl", args);
|
|
125
|
+
if (r.status === 0)
|
|
126
|
+
return;
|
|
127
|
+
throw new Error(`systemctl ${args.join(" ")} failed (exit ${String(r.status)}).\n` +
|
|
128
|
+
`${r.stderr || r.stdout}`.trim());
|
|
129
|
+
}
|
|
101
130
|
export function createSystemdBackend(run, homeDir, xdgConfigHome) {
|
|
102
131
|
// Both forwarded, neither defaulted here: `unitPath` owns the precedence, and
|
|
103
132
|
// defaulting `homeDir` to `os.homedir()` at this layer would collapse "the
|
|
@@ -129,10 +158,33 @@ export function createSystemdBackend(run, homeDir, xdgConfigHome) {
|
|
|
129
158
|
run("systemctl", ["--user", "daemon-reload"]);
|
|
130
159
|
return existed;
|
|
131
160
|
},
|
|
161
|
+
/**
|
|
162
|
+
* The lifecycle three (#873), which on systemd are just the verbs.
|
|
163
|
+
*
|
|
164
|
+
* None of them touches `enable`/`disable`: those decide whether Paddock
|
|
165
|
+
* comes back at your NEXT login, which is a different question from whether
|
|
166
|
+
* it is running now, and quietly answering both would make `stop` a
|
|
167
|
+
* surprise the following morning. `uninstall` is where that link is cut.
|
|
168
|
+
*
|
|
169
|
+
* `restart` re-reads the unit itself, but a `daemon-reload` first is what
|
|
170
|
+
* makes an EDITED unit take effect rather than the manager's cached copy —
|
|
171
|
+
* the same reason `install` reloads before enabling.
|
|
172
|
+
*/
|
|
173
|
+
start() {
|
|
174
|
+
systemctlOrThrow(run, ["--user", "start", SYSTEMD_UNIT]);
|
|
175
|
+
},
|
|
176
|
+
stop() {
|
|
177
|
+
systemctlOrThrow(run, ["--user", "stop", SYSTEMD_UNIT]);
|
|
178
|
+
},
|
|
179
|
+
restart() {
|
|
180
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
181
|
+
systemctlOrThrow(run, ["--user", "restart", SYSTEMD_UNIT]);
|
|
182
|
+
},
|
|
132
183
|
status() {
|
|
133
184
|
if (!fs.existsSync(file))
|
|
134
|
-
return { registered: false, running: false, argv: [] };
|
|
135
|
-
const
|
|
185
|
+
return { registered: false, running: false, argv: [], keepsAlive: true };
|
|
186
|
+
const text = fs.readFileSync(file, "utf8");
|
|
187
|
+
const argv = parseUnitArgv(text);
|
|
136
188
|
const active = run("systemctl", ["--user", "is-active", SYSTEMD_UNIT]);
|
|
137
189
|
const pid = run("systemctl", ["--user", "show", "-p", "MainPID", "--value", SYSTEMD_UNIT]);
|
|
138
190
|
const mainPid = pid.stdout.trim();
|
|
@@ -141,6 +193,7 @@ export function createSystemdBackend(run, homeDir, xdgConfigHome) {
|
|
|
141
193
|
running: active.stdout.trim() === "active",
|
|
142
194
|
...(mainPid !== "" && mainPid !== "0" ? { pid: mainPid } : {}),
|
|
143
195
|
argv: argv.slice(2),
|
|
196
|
+
keepsAlive: unitKeepsAlive(text),
|
|
144
197
|
};
|
|
145
198
|
},
|
|
146
199
|
logsHint() {
|
|
@@ -141,6 +141,43 @@ export interface DiscoverContext {
|
|
|
141
141
|
}
|
|
142
142
|
/** The ephemeral roots on THIS machine — the well-known set plus `$TMPDIR`. */
|
|
143
143
|
export declare function defaultTempRoots(): string[];
|
|
144
|
+
/**
|
|
145
|
+
* Drop the transcript folders paddock planted itself, BEFORE anything is counted
|
|
146
|
+
* (issue #865).
|
|
147
|
+
*
|
|
148
|
+
* Paddock bridges each workspace's `.chats` into its Claude home as a symlink at
|
|
149
|
+
* boot — the root workspace's and the sweeper's exist on an instance nobody has
|
|
150
|
+
* touched yet. They are transcript folders by every structural test, so the scan
|
|
151
|
+
* saw two of them, excluded both as `no-recorded-cwd` (they are empty), and
|
|
152
|
+
* reported `scanned: 2`. Reporting them as *scanned* is the bug: `scanned === 0`
|
|
153
|
+
* is what the UI keys "no Claude Code history on this machine" off, and paddock's
|
|
154
|
+
* own bookkeeping is not the user's history.
|
|
155
|
+
*
|
|
156
|
+
* ## The test, and why it is this one
|
|
157
|
+
*
|
|
158
|
+
* A folder is paddock's own when it resolves INTO paddock's own storage
|
|
159
|
+
* (`ownRoots`: the projects root and the data dir) while resolving OUTSIDE every
|
|
160
|
+
* Claude home. Both halves are load-bearing:
|
|
161
|
+
*
|
|
162
|
+
* - the second half alone would drop everything — a real folder physically
|
|
163
|
+
* lives in `<claudeHome>/projects/`, which is itself inside the data dir;
|
|
164
|
+
* - the first half alone would drop the user's OWN mirrored folders (#620),
|
|
165
|
+
* which resolve into `~/.claude` — inside `legacyClaudeHome`, and exactly the
|
|
166
|
+
* history this feature exists to offer.
|
|
167
|
+
*
|
|
168
|
+
* So: resolves into a claude home ⇒ the user's, keep. Resolves into paddock's
|
|
169
|
+
* storage but no claude home ⇒ a `.chats` bridge paddock made, drop.
|
|
170
|
+
*
|
|
171
|
+
* Note this is deliberately NOT another {@link DiscoverExclusion}. An exclusion
|
|
172
|
+
* is a directory the user has that we are declining to offer, and it is counted
|
|
173
|
+
* and shown as such; these are folders that were never theirs to begin with.
|
|
174
|
+
*/
|
|
175
|
+
export declare function withoutOwnTranscriptFolders(folders: TranscriptFolder[], roots: {
|
|
176
|
+
/** Paddock's own storage: the projects root and the data dir. */
|
|
177
|
+
ownRoots: string[];
|
|
178
|
+
/** Both Claude homes — paddock's and the user's. */
|
|
179
|
+
claudeHomes: string[];
|
|
180
|
+
}): TranscriptFolder[];
|
|
144
181
|
/**
|
|
145
182
|
* Is this an ephemeral scratch directory?
|
|
146
183
|
*
|
|
@@ -61,6 +61,18 @@
|
|
|
61
61
|
*
|
|
62
62
|
* Surviving directories are ranked by non-noise session count, then by recency.
|
|
63
63
|
*
|
|
64
|
+
* ## Paddock's own folders never enter the scan at all (#865)
|
|
65
|
+
*
|
|
66
|
+
* {@link withoutOwnTranscriptFolders} runs BEFORE any of this, and it is not one
|
|
67
|
+
* more exclusion rule: a folder it drops is not counted in `scanned` either.
|
|
68
|
+
* That distinction is the whole point. Paddock plants a symlink into its Claude
|
|
69
|
+
* home for each workspace's `.chats` at boot, so a machine that has never run
|
|
70
|
+
* Claude Code reported `scanned: 2, excluded: {"no-recorded-cwd": 2}` — and
|
|
71
|
+
* `scanned === 0`, the one signal that separates "no history here" from "all of
|
|
72
|
+
* it was filtered out", was therefore unreachable in the case it was written
|
|
73
|
+
* for. The UI then told a first-time user their history was "already a project,
|
|
74
|
+
* or was filtered out", on a machine with no history at all.
|
|
75
|
+
*
|
|
64
76
|
* ## Why counts are reported rather than dropped
|
|
65
77
|
*
|
|
66
78
|
* `DiscoverResult.excluded` carries how many directories each rule ate. Without it a
|
|
@@ -96,6 +108,45 @@ const TEMP_PATH_ROOTS = [
|
|
|
96
108
|
export function defaultTempRoots() {
|
|
97
109
|
return [...TEMP_PATH_ROOTS, os.tmpdir()];
|
|
98
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Drop the transcript folders paddock planted itself, BEFORE anything is counted
|
|
113
|
+
* (issue #865).
|
|
114
|
+
*
|
|
115
|
+
* Paddock bridges each workspace's `.chats` into its Claude home as a symlink at
|
|
116
|
+
* boot — the root workspace's and the sweeper's exist on an instance nobody has
|
|
117
|
+
* touched yet. They are transcript folders by every structural test, so the scan
|
|
118
|
+
* saw two of them, excluded both as `no-recorded-cwd` (they are empty), and
|
|
119
|
+
* reported `scanned: 2`. Reporting them as *scanned* is the bug: `scanned === 0`
|
|
120
|
+
* is what the UI keys "no Claude Code history on this machine" off, and paddock's
|
|
121
|
+
* own bookkeeping is not the user's history.
|
|
122
|
+
*
|
|
123
|
+
* ## The test, and why it is this one
|
|
124
|
+
*
|
|
125
|
+
* A folder is paddock's own when it resolves INTO paddock's own storage
|
|
126
|
+
* (`ownRoots`: the projects root and the data dir) while resolving OUTSIDE every
|
|
127
|
+
* Claude home. Both halves are load-bearing:
|
|
128
|
+
*
|
|
129
|
+
* - the second half alone would drop everything — a real folder physically
|
|
130
|
+
* lives in `<claudeHome>/projects/`, which is itself inside the data dir;
|
|
131
|
+
* - the first half alone would drop the user's OWN mirrored folders (#620),
|
|
132
|
+
* which resolve into `~/.claude` — inside `legacyClaudeHome`, and exactly the
|
|
133
|
+
* history this feature exists to offer.
|
|
134
|
+
*
|
|
135
|
+
* So: resolves into a claude home ⇒ the user's, keep. Resolves into paddock's
|
|
136
|
+
* storage but no claude home ⇒ a `.chats` bridge paddock made, drop.
|
|
137
|
+
*
|
|
138
|
+
* Note this is deliberately NOT another {@link DiscoverExclusion}. An exclusion
|
|
139
|
+
* is a directory the user has that we are declining to offer, and it is counted
|
|
140
|
+
* and shown as such; these are folders that were never theirs to begin with.
|
|
141
|
+
*/
|
|
142
|
+
export function withoutOwnTranscriptFolders(folders, roots) {
|
|
143
|
+
return folders.filter((folder) => {
|
|
144
|
+
const real = folder.realPath;
|
|
145
|
+
if (roots.claudeHomes.some((home) => isPathInside(real, home)))
|
|
146
|
+
return true;
|
|
147
|
+
return !roots.ownRoots.some((root) => isPathInside(real, root));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
99
150
|
/**
|
|
100
151
|
* Is this an ephemeral scratch directory?
|
|
101
152
|
*
|
|
@@ -53,6 +53,7 @@ import { type PaddockTrigger } from "./trigger-config.js";
|
|
|
53
53
|
import { type McpSources } from "./claude-mcp.js";
|
|
54
54
|
import { type HostPluginSource } from "./claude-plugins.js";
|
|
55
55
|
import { type AdoptableSummary, type FilterReason, type TranscriptFolder } from "./adoptable.js";
|
|
56
|
+
import { type SessionWithActivity } from "./last-activity.js";
|
|
56
57
|
/**
|
|
57
58
|
* One session an import did NOT bring in, with a reason a UI can show verbatim.
|
|
58
59
|
*
|
|
@@ -869,7 +870,7 @@ export declare class HerdctlService {
|
|
|
869
870
|
* zero added cost unless the project actually declares hooks. The extra per-hook
|
|
870
871
|
* scans only kick in for a project that has hook chats to show.
|
|
871
872
|
*/
|
|
872
|
-
listSessions(project: Project): Promise<
|
|
873
|
+
listSessions(project: Project): Promise<SessionWithActivity[]>;
|
|
873
874
|
/**
|
|
874
875
|
* The on-disk job-record reads (run history + the unread badge) live in
|
|
875
876
|
* `./herdctl-jobs.js` as pure functions over `<stateDir>/jobs` (issue #403);
|
|
@@ -62,6 +62,7 @@ import * as jobs from "./herdctl-jobs.js";
|
|
|
62
62
|
import { JobsDirIndex } from "./herdctl-jobs-index.js";
|
|
63
63
|
import { AdoptableIndex, } from "./adoptable.js";
|
|
64
64
|
import { ROOT_KEY } from "./project-paths.js";
|
|
65
|
+
import { withLastActivity } from "./last-activity.js";
|
|
65
66
|
/**
|
|
66
67
|
* The name/visibility helpers + tool/model constants are pure (they only read
|
|
67
68
|
* their arguments, no fleet/cfg/session state), so they live in a sibling module
|
|
@@ -1519,9 +1520,9 @@ export class HerdctlService {
|
|
|
1519
1520
|
*/
|
|
1520
1521
|
async listSessions(project) {
|
|
1521
1522
|
const agentNames = visibleProjectAgentNames(project);
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1523
|
+
const perAgent = agentNames.length === 1
|
|
1524
|
+
? [await this.manager.getAgentSessions(agentNames[0])]
|
|
1525
|
+
: await Promise.all(agentNames.map((name) => this.manager.getAgentSessions(name).catch(() => [])));
|
|
1525
1526
|
const seen = new Set();
|
|
1526
1527
|
const merged = [];
|
|
1527
1528
|
for (const list of perAgent) {
|
|
@@ -1532,9 +1533,13 @@ export class HerdctlService {
|
|
|
1532
1533
|
merged.push(s);
|
|
1533
1534
|
}
|
|
1534
1535
|
}
|
|
1535
|
-
//
|
|
1536
|
-
|
|
1537
|
-
return
|
|
1536
|
+
// Recency, from the last real MESSAGE in each transcript rather than from
|
|
1537
|
+
// its file mtime (#863) — and this is also where the single-agent fast path
|
|
1538
|
+
// stopped being free. It used to return herdctl's own mtime-descending
|
|
1539
|
+
// order untouched; that order is the bug, so the common case has to be
|
|
1540
|
+
// re-sorted too. What it costs is a memoised tail read per chat, which is
|
|
1541
|
+
// why `lastMessageAt` seeks to the end rather than parsing.
|
|
1542
|
+
return withLastActivity(project.dir, merged);
|
|
1538
1543
|
}
|
|
1539
1544
|
/**
|
|
1540
1545
|
* The on-disk job-record reads (run history + the unread badge) live in
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { DiscoveredSession } from "@herdctl/core";
|
|
2
|
+
/**
|
|
3
|
+
* A session plus the timestamp of its last real message.
|
|
4
|
+
*
|
|
5
|
+
* A widening rather than a replacement, because `mtime` still has a job:
|
|
6
|
+
* it is the cache key for auto-name, preview, sidechain detection and usage
|
|
7
|
+
* throughout this package and inside herdctl. Overwriting it with the corrected
|
|
8
|
+
* value would fix the display and silently break every one of those caches —
|
|
9
|
+
* they would stop noticing that a file had changed.
|
|
10
|
+
*/
|
|
11
|
+
export interface SessionWithActivity extends DiscoveredSession {
|
|
12
|
+
/** ISO 8601 timestamp of the last real message; absent if none was datable. */
|
|
13
|
+
lastMessageAt?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Timestamp of the last real message in a project chat's transcript.
|
|
17
|
+
*
|
|
18
|
+
* `undefined` — a missing file, an unreadable one, a transcript of nothing but
|
|
19
|
+
* control records — is a legitimate answer meaning "nothing better than the
|
|
20
|
+
* mtime", and every caller treats it that way.
|
|
21
|
+
*/
|
|
22
|
+
export declare function lastMessageAt(projectDir: string, sessionId: string): Promise<string | undefined>;
|
|
23
|
+
/**
|
|
24
|
+
* Attach `lastMessageAt` to each session and order them newest-first.
|
|
25
|
+
*
|
|
26
|
+
* The sort lives here rather than in the client because the client is not the
|
|
27
|
+
* only reader: the MCP `list_chats` tool and the project-detail payload both
|
|
28
|
+
* take the server's order as given. The comparison falls back to `mtime`
|
|
29
|
+
* per-session rather than for the list as a whole, so a chat with no datable
|
|
30
|
+
* record still sorts sensibly against ones that have.
|
|
31
|
+
*/
|
|
32
|
+
export declare function withLastActivity(projectDir: string, sessions: readonly DiscoveredSession[]): Promise<SessionWithActivity[]>;
|
|
33
|
+
/** Epoch ms a session should be ordered by: its last message, else its mtime. */
|
|
34
|
+
export declare function activityMs(s: SessionWithActivity): number;
|