@edspencer/paddock 0.66.1 → 0.67.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/package.json +1 -1
- package/packages/server/dist/herdctl-jobs.d.ts +20 -0
- package/packages/server/dist/herdctl-jobs.js +94 -0
- package/packages/server/dist/herdctl.d.ts +46 -0
- package/packages/server/dist/herdctl.js +58 -0
- package/packages/server/dist/instance-config.d.ts +56 -5
- package/packages/server/dist/instance-config.js +206 -24
- package/packages/server/dist/project-paths.d.ts +46 -1
- package/packages/server/dist/project-paths.js +66 -2
- package/packages/server/dist/project-types.d.ts +23 -0
- package/packages/server/dist/project-types.js +35 -0
- package/packages/server/dist/projects.d.ts +5 -2
- package/packages/server/dist/projects.js +71 -17
- package/packages/server/dist/queued-message.d.ts +32 -2
- package/packages/server/dist/queued-message.js +87 -8
- package/packages/server/dist/routes/chats.js +41 -10
- package/packages/server/dist/routes/meta.js +32 -5
- package/packages/server/dist/routes/projects.js +71 -7
- package/packages/server/dist/session-hub.d.ts +10 -0
- package/packages/server/dist/session-hub.js +16 -2
- package/packages/server/dist/ws-protocol.d.ts +67 -0
- package/packages/server/dist/ws-protocol.js +28 -13
- package/packages/server/dist/ws.js +90 -24
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-sDbCoFxC.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-CRQP3Cfe.js → MarkdownRenderer-MaSe5aAI.js} +11 -11
- package/packages/web/dist/assets/ProjectMenu-DmWpr9od.js +2 -0
- package/packages/web/dist/assets/ProjectRedirect-BJeDERUd.js +2 -0
- package/packages/web/dist/assets/ProjectView-Bbecs1u7.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-BE4cAtu5.js +2 -0
- package/packages/web/dist/assets/Surfaces-DtSsbnLh.js +2 -0
- package/packages/web/dist/assets/{arc-DqMSW0As.js → arc-NLb6J_GZ.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CNkQD1jU.js → architectureDiagram-3BPJPVTR-BbiSIctn.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BCMiOmt6.js → blockDiagram-GPEHLZMM-Br1sRzRE.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CU2soCji.js → c4Diagram-AAUBKEIU-DUvJurko.js} +1 -1
- package/packages/web/dist/assets/channel-bezC0w5b.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-nKhlJNTt.js → chunk-2J33WTMH-Cm_E_LAD.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-YRTZj9zI.js → chunk-4BX2VUAB-CK6R2MCN.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-HO60VE45.js → chunk-55IACEB6-D7HXvxGy.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-WZJH-boe.js → chunk-727SXJPM-BLwevzlY.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-PvDC7gYB.js → chunk-AQP2D5EJ-CpLWD5so.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BNRMTplH.js → chunk-FMBD7UC4-BNlt1t19.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-2Q2ei8TR.js → chunk-ND2GUHAM-CIt5gAq8.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CG9VV7M6.js → chunk-QZHKN3VN-B1uwIQ9c.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DMgrTmQU.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DMgrTmQU.js +2 -0
- package/packages/web/dist/assets/{core-DwSEw_oE.js → core-HXUN76Po.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BlMrBI7K.js → cose-bilkent-S5V4N54A-DJ4_5jxB.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-tNoaleNU.js → dagre-BM42HDAG-Bc9-haAu.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-DRVCTpHL.js → diagram-2AECGRRQ-nLOtPDzu.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-BdS-96WV.js → diagram-5GNKFQAL-DThV84R3.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-Dv1yxz3s.js → diagram-KO2AKTUF-C5hcR1vA.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BNXgifuS.js → diagram-LMA3HP47-BEFzrSHy.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-wlO0bxwP.js → diagram-OG6HWLK6-BY5lPsX7.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-ChoppAEq.js → erDiagram-TEJ5UH35-y_kB8ETg.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-C__aYyei.js → flowDiagram-I6XJVG4X-Cuhha5cF.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CjRe8foW.js → ganttDiagram-6RSMTGT7-DFJpciK5.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BGDQjiWq.js → gitGraphDiagram-PVQCEYII-FoA0yuh-.js} +1 -1
- package/packages/web/dist/assets/index-BxLurJvz.css +1 -0
- package/packages/web/dist/assets/index-C_PXsGTD.js +71 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BP-JaX-8.js → infoDiagram-5YYISTIA-CKYqD8-c.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-YnsAJBvC.js → ishikawaDiagram-YF4QCWOH-CCIxHIBw.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BM6JWTV9.js → journeyDiagram-JHISSGLW-lGYoSyk0.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-gR2O0IaL.js → kanban-definition-UN3LZRKU-B3D7CIwy.js} +1 -1
- package/packages/web/dist/assets/{linear-Dolq_eLR.js → linear-BiplJBex.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-D1T_GQW7.js → mermaid.core-BJ7aEinZ.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-C3bzIyLZ.js → mindmap-definition-RKZ34NQL-BiXcOtHo.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BrZ3ANGf.js → pieDiagram-4H26LBE5-tnkj45YF.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BvLCHAEi.js → quadrantDiagram-W4KKPZXB-LqReq3Gl.js} +1 -1
- package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CCzXAeaZ.js → requirementDiagram-4Y6WPE33-C1uk9Jty.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-2Gf-ERTg.js → sankeyDiagram-5OEKKPKP-eQQ3OKrV.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DMjDe28d.js → sequenceDiagram-3UESZ5HK-C6-a0lH4.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CENtj5dw.js → stateDiagram-AJRCARHV-CsTOkKqz.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Bsc-VlVI.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BW4gCpBB.js → timeline-definition-PNZ67QCA-DUBXH1x-.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CiIuqcT9.js → vennDiagram-CIIHVFJN-CcQrNnou.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-BQ1BPr5R.js → wardley-L42UT6IY-Br823uUV.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DBq9Ailv.js → wardleyDiagram-YWT4CUSO-DRBECQc_.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D24VDECy.js → xychartDiagram-2RQKCTM6--HtSFP24.js} +1 -1
- package/packages/web/dist/fonts/alegreya-latin-italic.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-latin.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-sc-latin-500.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-sc-latin-700.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-400.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-700.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-italic.woff2 +0 -0
- package/packages/web/dist/fonts/iosevka-terminal-400.woff2 +0 -0
- package/packages/web/dist/fonts/iosevka-terminal-700.woff2 +0 -0
- package/packages/web/dist/fonts/martian-mono-latin.woff2 +0 -0
- package/packages/web/dist/fonts/saira-latin.woff2 +0 -0
- package/packages/web/dist/index.html +61 -12
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-1BzMnJPo.js +0 -2
- package/packages/web/dist/assets/ProjectRedirect-Dd3h5V8h.js +0 -2
- package/packages/web/dist/assets/ProjectView-BRk6l152.js +0 -13
- package/packages/web/dist/assets/ProjectsGrid-BaUE8zaF.js +0 -2
- package/packages/web/dist/assets/channel-Ynyq4GPh.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-1Pp25wwy.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-1Pp25wwy.js +0 -2
- package/packages/web/dist/assets/format-u-YtEZFd.js +0 -2
- package/packages/web/dist/assets/index-B3K5nsLe.css +0 -1
- package/packages/web/dist/assets/index-Cc5bQxZk.js +0 -71
- package/packages/web/dist/assets/queued-DGmoZW59.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-F7o6JfFe.js +0 -2
package/package.json
CHANGED
|
@@ -72,6 +72,26 @@ export declare function listProjectRuns(stateDir: string, project: Project, limi
|
|
|
72
72
|
* view degrades to config-only rather than failing to render.
|
|
73
73
|
*/
|
|
74
74
|
export declare function listRunsForAgents(stateDir: string, agents: string[], limit?: number): Promise<JobMetadata[]>;
|
|
75
|
+
/**
|
|
76
|
+
* Drop the job records of one or more CHATS (#732) — called when Paddock removes
|
|
77
|
+
* a chat from a project, so the record cannot outlive the transcript and keep
|
|
78
|
+
* the sidebar badge counting a chat that is no longer there to open.
|
|
79
|
+
*/
|
|
80
|
+
export declare function purgeSessionJobs(stateDir: string, sessionIds: string[]): Promise<number>;
|
|
81
|
+
/**
|
|
82
|
+
* Drop the job records of one or more AGENTS (#734) — called when a project is
|
|
83
|
+
* deleted, for every agent name that project owned (keeper, sweeper, hooks,
|
|
84
|
+
* triggers). Agent-keyed rather than project-keyed because the agent name is
|
|
85
|
+
* what the record actually carries.
|
|
86
|
+
*
|
|
87
|
+
* This is the containment half of the fix rather than the structural one: the
|
|
88
|
+
* durable answer to "a re-created project inherits the old one's records" is to
|
|
89
|
+
* key them by a stable project id instead of the user-controlled, reusable slug.
|
|
90
|
+
* That is a herdctl-side identity change (records are herdctl's format, written
|
|
91
|
+
* by its runtimes); purging on delete makes the leak impossible NOW without
|
|
92
|
+
* waiting for it, and stays correct afterwards.
|
|
93
|
+
*/
|
|
94
|
+
export declare function purgeAgentJobs(stateDir: string, agents: string[]): Promise<number>;
|
|
75
95
|
/**
|
|
76
96
|
* Point every herdctl job record for `sessionId` at the project's keeper so
|
|
77
97
|
* the core attribution index (last-write-wins per session) lists the session
|
|
@@ -138,6 +138,100 @@ export async function listRunsForAgents(stateDir, agents, limit = 200) {
|
|
|
138
138
|
const filtered = jobs.filter((j) => wanted.has(j.agent));
|
|
139
139
|
return limit > 0 ? filtered.slice(0, limit) : filtered;
|
|
140
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Delete every job record matching `match`, plus each one's sibling `.jsonl`
|
|
143
|
+
* output file. Returns how many records were removed.
|
|
144
|
+
*
|
|
145
|
+
* The shared inverse of the writes above (#732 / #734). The jobs directory is
|
|
146
|
+
* append-only and fleet-wide, and until now NOTHING ever removed a record — so a
|
|
147
|
+
* record outlived whatever it described. Two user-visible consequences, one
|
|
148
|
+
* mechanism:
|
|
149
|
+
*
|
|
150
|
+
* - A deleted CHAT kept feeding {@link lastTurnCompletedAtByProject}, i.e. the
|
|
151
|
+
* sidebar unread badge, with no chat left to open to clear it (#732).
|
|
152
|
+
* - A deleted PROJECT left its keeper's whole history behind, keyed by the
|
|
153
|
+
* agent name — which is derived from the slug, which is derived from the
|
|
154
|
+
* NAME. Re-creating a project called the same thing therefore inherited the
|
|
155
|
+
* previous incarnation's `/runs`, prompt text and reply summaries included
|
|
156
|
+
* (#734).
|
|
157
|
+
*
|
|
158
|
+
* Records are matched by reading them, not by parsing filenames: a job id
|
|
159
|
+
* encodes only a date and six characters of the session id, which is neither an
|
|
160
|
+
* agent nor a reliable session key.
|
|
161
|
+
*
|
|
162
|
+
* Best-effort per file, deliberately: a record we cannot read is a record we
|
|
163
|
+
* cannot prove is ours, so it is left alone rather than removed on a guess, and
|
|
164
|
+
* an unlink failure never fails the delete the user actually asked for. The
|
|
165
|
+
* caller invalidates {@link JobsDirIndex} afterwards — the index also drops
|
|
166
|
+
* vanished files on its own next scan, so this is belt-and-braces.
|
|
167
|
+
*/
|
|
168
|
+
async function purgeJobs(stateDir, match) {
|
|
169
|
+
const jobsDir = jobsDirOf(stateDir);
|
|
170
|
+
let entries = [];
|
|
171
|
+
try {
|
|
172
|
+
entries = await fs.readdir(jobsDir);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return 0; // no jobs dir yet — nothing to purge
|
|
176
|
+
}
|
|
177
|
+
let removed = 0;
|
|
178
|
+
for (const name of entries) {
|
|
179
|
+
if (!name.endsWith(".yaml"))
|
|
180
|
+
continue;
|
|
181
|
+
const file = path.join(jobsDir, name);
|
|
182
|
+
let parsed = null;
|
|
183
|
+
try {
|
|
184
|
+
parsed = YAML.parse(await fs.readFile(file, "utf8"));
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!parsed || !match(parsed))
|
|
190
|
+
continue;
|
|
191
|
+
await fs.rm(file, { force: true }).catch(() => undefined);
|
|
192
|
+
// The record names its own output file; fall back to the `<id>.jsonl`
|
|
193
|
+
// convention {@link writeAgentAdoptionJob} writes when it does not.
|
|
194
|
+
const output = typeof parsed.output_file === "string" && parsed.output_file.length > 0
|
|
195
|
+
? parsed.output_file
|
|
196
|
+
: path.join(jobsDir, `${name.slice(0, -".yaml".length)}.jsonl`);
|
|
197
|
+
// Only ever unlink an output file that lives in the jobs dir we just read.
|
|
198
|
+
if (path.dirname(path.resolve(output)) === path.resolve(jobsDir)) {
|
|
199
|
+
await fs.rm(output, { force: true }).catch(() => undefined);
|
|
200
|
+
}
|
|
201
|
+
removed++;
|
|
202
|
+
}
|
|
203
|
+
return removed;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Drop the job records of one or more CHATS (#732) — called when Paddock removes
|
|
207
|
+
* a chat from a project, so the record cannot outlive the transcript and keep
|
|
208
|
+
* the sidebar badge counting a chat that is no longer there to open.
|
|
209
|
+
*/
|
|
210
|
+
export async function purgeSessionJobs(stateDir, sessionIds) {
|
|
211
|
+
if (sessionIds.length === 0)
|
|
212
|
+
return 0;
|
|
213
|
+
const wanted = new Set(sessionIds);
|
|
214
|
+
return purgeJobs(stateDir, (r) => typeof r.session_id === "string" && wanted.has(r.session_id));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Drop the job records of one or more AGENTS (#734) — called when a project is
|
|
218
|
+
* deleted, for every agent name that project owned (keeper, sweeper, hooks,
|
|
219
|
+
* triggers). Agent-keyed rather than project-keyed because the agent name is
|
|
220
|
+
* what the record actually carries.
|
|
221
|
+
*
|
|
222
|
+
* This is the containment half of the fix rather than the structural one: the
|
|
223
|
+
* durable answer to "a re-created project inherits the old one's records" is to
|
|
224
|
+
* key them by a stable project id instead of the user-controlled, reusable slug.
|
|
225
|
+
* That is a herdctl-side identity change (records are herdctl's format, written
|
|
226
|
+
* by its runtimes); purging on delete makes the leak impossible NOW without
|
|
227
|
+
* waiting for it, and stays correct afterwards.
|
|
228
|
+
*/
|
|
229
|
+
export async function purgeAgentJobs(stateDir, agents) {
|
|
230
|
+
if (agents.length === 0)
|
|
231
|
+
return 0;
|
|
232
|
+
const wanted = new Set(agents);
|
|
233
|
+
return purgeJobs(stateDir, (r) => typeof r.agent === "string" && wanted.has(r.agent));
|
|
234
|
+
}
|
|
141
235
|
/**
|
|
142
236
|
* Point every herdctl job record for `sessionId` at the project's keeper so
|
|
143
237
|
* the core attribution index (last-write-wins per session) lists the session
|
|
@@ -515,6 +515,52 @@ export declare class HerdctlService {
|
|
|
515
515
|
* — so `retained` is how a caller tells "gone" from "no longer shown here".
|
|
516
516
|
*/
|
|
517
517
|
deleteSession(agentName: string, sessionId: string): Promise<ChatDeletion>;
|
|
518
|
+
/**
|
|
519
|
+
* Drop the herdctl job records for chats that have left this instance (#732),
|
|
520
|
+
* so a record cannot outlive its transcript and keep the sidebar unread badge
|
|
521
|
+
* counting a chat there is no longer anything to open.
|
|
522
|
+
*
|
|
523
|
+
* Takes a LIST rather than one id, and is called by the delete routes rather
|
|
524
|
+
* than from inside {@link deleteSession}, for one reason: purging is a scan of
|
|
525
|
+
* the whole shared jobs directory, and the batch route deletes up to 500 chats
|
|
526
|
+
* in a loop. Per-chat purging would re-scan a 2,000-record directory 500 times
|
|
527
|
+
* (see `herdctl-jobs-index.ts` for what that costs). One pass, all ids.
|
|
528
|
+
*
|
|
529
|
+
* Callers pass only the chats whose transcript was actually REMOVED. Under
|
|
530
|
+
* `claude.transcripts: host` a delete RELEASES instead — the chat is still
|
|
531
|
+
* listed there, a known deferred gap (#693, asserted by
|
|
532
|
+
* `delete-chat-host-transcripts.test.ts`), and purging would close it as a
|
|
533
|
+
* side effect, because the job record is part of what re-attributes a
|
|
534
|
+
* structurally-discovered session to the keeper. Resolving somebody else's
|
|
535
|
+
* deferred decision by accident is not this change's business, and keeping the
|
|
536
|
+
* record for a chat that is still in the list is coherent anyway:
|
|
537
|
+
* `buildChatTurns` prunes against the live chat list, so the badge follows the
|
|
538
|
+
* list either way — and will stop counting the chat the moment #693 makes it
|
|
539
|
+
* leave.
|
|
540
|
+
*
|
|
541
|
+
* Best-effort: tidying state must never fail the delete the user asked for.
|
|
542
|
+
*/
|
|
543
|
+
purgeSessionJobs(sessionIds: string[]): Promise<void>;
|
|
544
|
+
/**
|
|
545
|
+
* Drop the herdctl job records of every agent a project owned (#734) — the
|
|
546
|
+
* records half of a project delete.
|
|
547
|
+
*
|
|
548
|
+
* Job records are keyed by AGENT NAME, and an agent name is derived from the
|
|
549
|
+
* slug, which is derived from the project NAME. So without this, deleting
|
|
550
|
+
* "Foo" and creating a new "Foo" hands the new project the old one's run
|
|
551
|
+
* history: prompts, reply summaries, and a phantom `chatTurns` entry that
|
|
552
|
+
* shows an unread badge on a project with zero chats. Files, `.chats/` and
|
|
553
|
+
* `read-state.json` were already cleaned; these were the one thing left.
|
|
554
|
+
*
|
|
555
|
+
* Takes the same hook/trigger name lists {@link removeProjectAgent} does, so
|
|
556
|
+
* the two together are the full inverse of the create flow — and so they stay
|
|
557
|
+
* in step: if a delete ever has to sweep DESCENDANT workspaces (nesting), both
|
|
558
|
+
* need the same widening, not just one. Matching is on the exact agent name,
|
|
559
|
+
* so deleting `foo` cannot touch a sibling `foo-bar`.
|
|
560
|
+
*
|
|
561
|
+
* Best-effort — the project directory is already gone by the time we get here.
|
|
562
|
+
*/
|
|
563
|
+
purgeProjectJobs(slug: string, hookNames?: string[], triggerNames?: string[]): Promise<void>;
|
|
518
564
|
/**
|
|
519
565
|
* Drop the agent-level session pointer when it names a transcript we just
|
|
520
566
|
* removed. Issue #730 — silent data loss, and the fix is one `rm` of a file
|
|
@@ -903,6 +903,64 @@ export class HerdctlService {
|
|
|
903
903
|
this.invalidateSessions(agentName);
|
|
904
904
|
return { removed: false, retained: true };
|
|
905
905
|
}
|
|
906
|
+
/**
|
|
907
|
+
* Drop the herdctl job records for chats that have left this instance (#732),
|
|
908
|
+
* so a record cannot outlive its transcript and keep the sidebar unread badge
|
|
909
|
+
* counting a chat there is no longer anything to open.
|
|
910
|
+
*
|
|
911
|
+
* Takes a LIST rather than one id, and is called by the delete routes rather
|
|
912
|
+
* than from inside {@link deleteSession}, for one reason: purging is a scan of
|
|
913
|
+
* the whole shared jobs directory, and the batch route deletes up to 500 chats
|
|
914
|
+
* in a loop. Per-chat purging would re-scan a 2,000-record directory 500 times
|
|
915
|
+
* (see `herdctl-jobs-index.ts` for what that costs). One pass, all ids.
|
|
916
|
+
*
|
|
917
|
+
* Callers pass only the chats whose transcript was actually REMOVED. Under
|
|
918
|
+
* `claude.transcripts: host` a delete RELEASES instead — the chat is still
|
|
919
|
+
* listed there, a known deferred gap (#693, asserted by
|
|
920
|
+
* `delete-chat-host-transcripts.test.ts`), and purging would close it as a
|
|
921
|
+
* side effect, because the job record is part of what re-attributes a
|
|
922
|
+
* structurally-discovered session to the keeper. Resolving somebody else's
|
|
923
|
+
* deferred decision by accident is not this change's business, and keeping the
|
|
924
|
+
* record for a chat that is still in the list is coherent anyway:
|
|
925
|
+
* `buildChatTurns` prunes against the live chat list, so the badge follows the
|
|
926
|
+
* list either way — and will stop counting the chat the moment #693 makes it
|
|
927
|
+
* leave.
|
|
928
|
+
*
|
|
929
|
+
* Best-effort: tidying state must never fail the delete the user asked for.
|
|
930
|
+
*/
|
|
931
|
+
async purgeSessionJobs(sessionIds) {
|
|
932
|
+
await jobs.purgeSessionJobs(this.cfg.stateDir, sessionIds).catch(() => 0);
|
|
933
|
+
this.jobsIndexOrNull?.invalidate();
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Drop the herdctl job records of every agent a project owned (#734) — the
|
|
937
|
+
* records half of a project delete.
|
|
938
|
+
*
|
|
939
|
+
* Job records are keyed by AGENT NAME, and an agent name is derived from the
|
|
940
|
+
* slug, which is derived from the project NAME. So without this, deleting
|
|
941
|
+
* "Foo" and creating a new "Foo" hands the new project the old one's run
|
|
942
|
+
* history: prompts, reply summaries, and a phantom `chatTurns` entry that
|
|
943
|
+
* shows an unread badge on a project with zero chats. Files, `.chats/` and
|
|
944
|
+
* `read-state.json` were already cleaned; these were the one thing left.
|
|
945
|
+
*
|
|
946
|
+
* Takes the same hook/trigger name lists {@link removeProjectAgent} does, so
|
|
947
|
+
* the two together are the full inverse of the create flow — and so they stay
|
|
948
|
+
* in step: if a delete ever has to sweep DESCENDANT workspaces (nesting), both
|
|
949
|
+
* need the same widening, not just one. Matching is on the exact agent name,
|
|
950
|
+
* so deleting `foo` cannot touch a sibling `foo-bar`.
|
|
951
|
+
*
|
|
952
|
+
* Best-effort — the project directory is already gone by the time we get here.
|
|
953
|
+
*/
|
|
954
|
+
async purgeProjectJobs(slug, hookNames = [], triggerNames = []) {
|
|
955
|
+
const agents = [
|
|
956
|
+
keeperAgentName(slug),
|
|
957
|
+
sweeperAgentName(slug),
|
|
958
|
+
...hookNames.map((name) => hookAgentName(slug, name)),
|
|
959
|
+
...triggerNames.map((name) => triggerAgentName(slug, name)),
|
|
960
|
+
];
|
|
961
|
+
await jobs.purgeAgentJobs(this.cfg.stateDir, agents).catch(() => 0);
|
|
962
|
+
this.jobsIndexOrNull?.invalidate();
|
|
963
|
+
}
|
|
906
964
|
/**
|
|
907
965
|
* Drop the agent-level session pointer when it names a transcript we just
|
|
908
966
|
* removed. Issue #730 — silent data loss, and the fix is one `rm` of a file
|
|
@@ -74,7 +74,17 @@ export interface InstanceConfigFieldDto {
|
|
|
74
74
|
help?: string;
|
|
75
75
|
type: FieldType;
|
|
76
76
|
enumValues?: readonly string[];
|
|
77
|
+
/** EFFECTIVE now: the value the running (frozen) process resolved at boot. */
|
|
77
78
|
value: unknown;
|
|
79
|
+
/**
|
|
80
|
+
* PENDING: what this field would resolve to if the process restarted right
|
|
81
|
+
* now — i.e. what is in `paddock.config.yaml` this instant (or the built-in
|
|
82
|
+
* default where the file says nothing, or `value` where an env var wins). This
|
|
83
|
+
* is what the editor renders and what a save round-trips through (#722).
|
|
84
|
+
*/
|
|
85
|
+
pendingValue: unknown;
|
|
86
|
+
/** `pendingValue` differs from `value` — the file has diverged from the process. */
|
|
87
|
+
pendingRestart: boolean;
|
|
78
88
|
default: unknown;
|
|
79
89
|
editable: boolean;
|
|
80
90
|
sensitive: boolean;
|
|
@@ -92,9 +102,33 @@ export interface InstanceConfigDto {
|
|
|
92
102
|
groups: InstanceConfigGroupDto[];
|
|
93
103
|
/** Absolute path of the file a PUT writes to (informational). */
|
|
94
104
|
configPath: string;
|
|
95
|
-
/**
|
|
96
|
-
|
|
105
|
+
/**
|
|
106
|
+
* At least one editable field's `pendingValue` differs from its `value`: the
|
|
107
|
+
* file on disk no longer describes the running process, and a restart would
|
|
108
|
+
* change behaviour. Was hardcoded `false` before #722 — which meant nothing
|
|
109
|
+
* ever told an operator their own save was unapplied.
|
|
110
|
+
*/
|
|
111
|
+
restartRequired: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Fingerprint of the config file as read for THIS response (`null` when the
|
|
114
|
+
* file does not exist yet). A client echoes it back as `expectedVersion` on
|
|
115
|
+
* the PUT; the server refuses the write if the file changed meanwhile, so two
|
|
116
|
+
* tabs conflict loudly instead of silently last-writer-winning (#722).
|
|
117
|
+
*/
|
|
118
|
+
configVersion: string | null;
|
|
119
|
+
/**
|
|
120
|
+
* Set when the file exists but could not be read or parsed. Pending values are
|
|
121
|
+
* unknowable in that state, so they fall back to the effective ones — better
|
|
122
|
+
* to say why than to silently report "nothing pending".
|
|
123
|
+
*/
|
|
124
|
+
configFileError?: string;
|
|
97
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* The current fingerprint of the config file (`null` when absent) — the PUT
|
|
128
|
+
* path's half of the optimistic-concurrency check. Deliberately re-reads rather
|
|
129
|
+
* than trusting a cached value: the point is to notice a write we did not make.
|
|
130
|
+
*/
|
|
131
|
+
export declare function instanceConfigVersion(configPath: string): string | null;
|
|
98
132
|
/**
|
|
99
133
|
* Resolve the path a PUT writes to: an explicit `PADDOCK_CONFIG` env var wins
|
|
100
134
|
* (the same rule {@link import("./config.js").loadConfigFile} reads it back
|
|
@@ -102,9 +136,25 @@ export interface InstanceConfigDto {
|
|
|
102
136
|
*/
|
|
103
137
|
export declare function instanceConfigPath(cfg: PaddockConfig): string;
|
|
104
138
|
/**
|
|
105
|
-
* Build the grouped GET DTO from the resolved (frozen) config
|
|
106
|
-
*
|
|
107
|
-
*
|
|
139
|
+
* Build the grouped GET DTO from the resolved (frozen) config, the live env AND
|
|
140
|
+
* the config file on disk. Each field reports:
|
|
141
|
+
*
|
|
142
|
+
* - `value` — EFFECTIVE now, out of the frozen boot config;
|
|
143
|
+
* - `pendingValue` — what a restart would resolve, out of the file this instant;
|
|
144
|
+
* - `pendingRestart` — those two differ.
|
|
145
|
+
*
|
|
146
|
+
* Reading the file is the load-bearing part (#722). While this built the DTO
|
|
147
|
+
* from the frozen config alone, `GET` could not observe ANY write — including
|
|
148
|
+
* the one the caller had just made — so a successful save appeared to revert,
|
|
149
|
+
* two tabs clobbered each other with nothing able to notice, and
|
|
150
|
+
* `restartRequired` had no choice but to be a hardcoded `false`.
|
|
151
|
+
*
|
|
152
|
+
* Pending values are computed for EDITABLE, non-env-shadowed fields only:
|
|
153
|
+
* - an env-shadowed field resolves to the same env value after a restart, so
|
|
154
|
+
* the file cannot make it diverge;
|
|
155
|
+
* - the read-only `advanced` bindings are normalised at boot (`dataDir` and
|
|
156
|
+
* friends are canonicalised, `port` is `Number()`-ed), so comparing them
|
|
157
|
+
* against raw file text would manufacture divergence that isn't there.
|
|
108
158
|
*/
|
|
109
159
|
export declare function buildInstanceConfig(cfg: PaddockConfig): InstanceConfigDto;
|
|
110
160
|
/** A rejected PUT: which field failed and why (surfaced as a 400). */
|
|
@@ -117,6 +167,7 @@ export declare class InstanceConfigError extends Error {
|
|
|
117
167
|
* {@link InstanceConfigError} on the first offending field:
|
|
118
168
|
* - an unknown key,
|
|
119
169
|
* - a read-only / non-editable key,
|
|
170
|
+
* - a key an env var currently shadows,
|
|
120
171
|
* - a value the field's `coerce` rejects.
|
|
121
172
|
* Returns the coerced `{ key, value }` pairs ready to write. A field whose value
|
|
122
173
|
* is `null` (an editable optional being cleared) is returned with `value: null`
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import fs from "node:fs";
|
|
31
31
|
import path from "node:path";
|
|
32
|
-
import {
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
import { parse, parseDocument } from "yaml";
|
|
33
34
|
import { DRIVE_MODES, DEFAULT_DRIVE_MODE, MODELS, isKnownModel } from "./models.js";
|
|
34
35
|
import { DEFAULT_MAX_SPAWN_DEPTH, isValidMaxSpawnDepth } from "./spawn-capability.js";
|
|
35
36
|
import { DEFAULT_RECOVERY } from "./recovery-config.js";
|
|
@@ -57,32 +58,82 @@ export const GROUPS = [
|
|
|
57
58
|
];
|
|
58
59
|
// --- coercion helpers -------------------------------------------------------
|
|
59
60
|
const asBool = (raw) => typeof raw === "boolean" ? { ok: true, value: raw } : { ok: false, error: "must be a boolean" };
|
|
61
|
+
/**
|
|
62
|
+
* Parse a JSON patch value that is meant to be a number, WITHOUT `Number()`'s
|
|
63
|
+
* coercions (issue #723). `Number()` happily turns `null` into `0`, `true` into
|
|
64
|
+
* `1` and `[7]` into `7` — so `{"recovery.maxRetries": null}`, documented to
|
|
65
|
+
* CLEAR the override, used to write a very meaningful `0` instead. Only a real
|
|
66
|
+
* number, or a non-blank numeric string (what an `<input type=number>` sends),
|
|
67
|
+
* counts; everything else is `NaN` and gets rejected by the callers below.
|
|
68
|
+
*/
|
|
69
|
+
const numeric = (raw) => {
|
|
70
|
+
if (typeof raw === "number")
|
|
71
|
+
return raw;
|
|
72
|
+
if (typeof raw === "string" && raw.trim() !== "")
|
|
73
|
+
return Number(raw.trim());
|
|
74
|
+
return NaN;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Ceiling for the numeric fields. Nothing downstream bounds these — a paste-o of
|
|
78
|
+
* `20000000` into a millisecond timeout is a 231-day debounce, and a token budget
|
|
79
|
+
* of 1e15 disables curation silently. A billion is far past any deliberate value
|
|
80
|
+
* (≈31 years in ms) and keeps every one of them a safe integer.
|
|
81
|
+
*/
|
|
82
|
+
const MAX_NUMBER = 1e9;
|
|
83
|
+
/**
|
|
84
|
+
* Ceiling for a plain `string` field, in characters. These are names, paths and
|
|
85
|
+
* URLs; without a bound a 200 KB paste into `brand.name` becomes a 200 KB
|
|
86
|
+
* `paddock.config.yaml` that every boot must parse. The prompt-shaped `text`
|
|
87
|
+
* fields have their own, much larger {@link MAX_PROMPT_CHARS}.
|
|
88
|
+
*/
|
|
89
|
+
const MAX_STRING_CHARS = 1024;
|
|
60
90
|
const posInt = (raw) => {
|
|
61
|
-
const n =
|
|
62
|
-
return Number.
|
|
91
|
+
const n = numeric(raw);
|
|
92
|
+
return Number.isInteger(n) && n > 0 && n <= MAX_NUMBER
|
|
63
93
|
? { ok: true, value: n }
|
|
64
|
-
: { ok: false, error:
|
|
94
|
+
: { ok: false, error: `must be a positive integer (at most ${MAX_NUMBER})` };
|
|
65
95
|
};
|
|
96
|
+
/** A non-negative integer, OR null/empty-string to clear the override (#723). */
|
|
66
97
|
const nonNegInt = (raw) => {
|
|
67
|
-
|
|
68
|
-
|
|
98
|
+
if (raw === null || raw === "" || raw === undefined)
|
|
99
|
+
return { ok: true, value: null };
|
|
100
|
+
const n = numeric(raw);
|
|
101
|
+
return Number.isInteger(n) && n >= 0 && n <= MAX_NUMBER
|
|
69
102
|
? { ok: true, value: n }
|
|
70
|
-
: {
|
|
103
|
+
: {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: `must be a non-negative integer up to ${MAX_NUMBER} (or blank to use the default)`,
|
|
106
|
+
};
|
|
71
107
|
};
|
|
72
108
|
/** A non-negative number, OR null/empty-string to clear the override. */
|
|
73
109
|
const optNonNegNumber = (raw) => {
|
|
74
110
|
if (raw === null || raw === "" || raw === undefined)
|
|
75
111
|
return { ok: true, value: null };
|
|
76
|
-
const n =
|
|
77
|
-
return Number.isFinite(n) && n >= 0
|
|
112
|
+
const n = numeric(raw);
|
|
113
|
+
return Number.isFinite(n) && n >= 0 && n <= MAX_NUMBER
|
|
78
114
|
? { ok: true, value: n }
|
|
79
|
-
: { ok: false, error:
|
|
115
|
+
: { ok: false, error: `must be a non-negative number up to ${MAX_NUMBER} (or blank to use the default)` };
|
|
116
|
+
};
|
|
117
|
+
const nonEmptyString = (raw) => {
|
|
118
|
+
if (typeof raw !== "string")
|
|
119
|
+
return { ok: false, error: "must be a non-empty string" };
|
|
120
|
+
const trimmed = raw.trim();
|
|
121
|
+
if (trimmed.length === 0)
|
|
122
|
+
return { ok: false, error: "must be a non-empty string" };
|
|
123
|
+
if (trimmed.length > MAX_STRING_CHARS) {
|
|
124
|
+
return { ok: false, error: `must be at most ${MAX_STRING_CHARS} characters (got ${trimmed.length})` };
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, value: trimmed };
|
|
80
127
|
};
|
|
81
|
-
const nonEmptyString = (raw) => typeof raw === "string" && raw.trim().length > 0
|
|
82
|
-
? { ok: true, value: raw.trim() }
|
|
83
|
-
: { ok: false, error: "must be a non-empty string" };
|
|
84
128
|
/** A string that may be blank (blank clears the override → falls back to default). */
|
|
85
|
-
const optString = (raw) =>
|
|
129
|
+
const optString = (raw) => {
|
|
130
|
+
if (typeof raw !== "string")
|
|
131
|
+
return { ok: false, error: "must be a string" };
|
|
132
|
+
const trimmed = raw.trim();
|
|
133
|
+
return trimmed.length <= MAX_STRING_CHARS
|
|
134
|
+
? { ok: true, value: trimmed }
|
|
135
|
+
: { ok: false, error: `must be at most ${MAX_STRING_CHARS} characters (got ${trimmed.length})` };
|
|
136
|
+
};
|
|
86
137
|
/**
|
|
87
138
|
* Upper bound on a prompt-shaped field, in characters. Nothing enforces a limit
|
|
88
139
|
* downstream — the text is concatenated into the system prompt of every single
|
|
@@ -120,17 +171,33 @@ const oneOf = (values) => (raw) => typeof raw === "string" && values.includes(ra
|
|
|
120
171
|
? { ok: true, value: raw }
|
|
121
172
|
: { ok: false, error: `must be one of: ${values.join(", ")}` };
|
|
122
173
|
const spawnDepth = (raw) => {
|
|
123
|
-
|
|
174
|
+
// Same shape as `nonNegInt`, for the same #723 reason: `Number(null)` is 0,
|
|
175
|
+
// which IS a valid depth — so "clear this override" wrote the one depth that
|
|
176
|
+
// switches every child's self-MCP off. Depth 0 stays settable; it just has to
|
|
177
|
+
// be asked for.
|
|
178
|
+
if (raw === null || raw === "" || raw === undefined)
|
|
179
|
+
return { ok: true, value: null };
|
|
180
|
+
const n = numeric(raw);
|
|
124
181
|
return isValidMaxSpawnDepth(n)
|
|
125
182
|
? { ok: true, value: n }
|
|
126
|
-
: { ok: false, error: "must be a small non-negative integer" };
|
|
183
|
+
: { ok: false, error: "must be a small non-negative integer (or blank to use the default)" };
|
|
127
184
|
};
|
|
128
185
|
const hexColor = (raw) => typeof raw === "string" && /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(raw.trim())
|
|
129
186
|
? { ok: true, value: raw.trim() }
|
|
130
187
|
: { ok: false, error: "must be a hex color like #c2603c" };
|
|
188
|
+
/** Bounds on a free-form list field, for the same reason as {@link MAX_STRING_CHARS}. */
|
|
189
|
+
const MAX_LIST_ITEMS = 64;
|
|
190
|
+
const MAX_LIST_ITEM_CHARS = 256;
|
|
131
191
|
const stringList = (raw) => {
|
|
132
192
|
const list = sanitizeAllowedTypes(raw);
|
|
133
|
-
|
|
193
|
+
if (!list)
|
|
194
|
+
return { ok: false, error: "must be a non-empty list of type/extension strings" };
|
|
195
|
+
if (list.length > MAX_LIST_ITEMS)
|
|
196
|
+
return { ok: false, error: `must have at most ${MAX_LIST_ITEMS} entries` };
|
|
197
|
+
if (list.some((s) => s.length > MAX_LIST_ITEM_CHARS)) {
|
|
198
|
+
return { ok: false, error: `each entry must be at most ${MAX_LIST_ITEM_CHARS} characters` };
|
|
199
|
+
}
|
|
200
|
+
return { ok: true, value: list };
|
|
134
201
|
};
|
|
135
202
|
/**
|
|
136
203
|
* Coerce a raw model allow-list (issue #457 Step 2) into a de-duped list of
|
|
@@ -290,6 +357,55 @@ function envOverride(envVars, whenDefined = false) {
|
|
|
290
357
|
}
|
|
291
358
|
return undefined;
|
|
292
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* Read + parse `paddock.config.yaml` for the GET path. Never throws: a missing
|
|
362
|
+
* file is the normal case, and a malformed one must not take down the screen
|
|
363
|
+
* that exists to fix it (the boot loader is the strict reader — see
|
|
364
|
+
* `loadConfigFile`).
|
|
365
|
+
*/
|
|
366
|
+
function readConfigFileSnapshot(configPath) {
|
|
367
|
+
let raw;
|
|
368
|
+
try {
|
|
369
|
+
raw = fs.readFileSync(configPath, "utf8");
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
if (err.code === "ENOENT")
|
|
373
|
+
return { data: null, version: null };
|
|
374
|
+
return { data: null, version: null, error: `failed to read ${configPath}: ${err.message}` };
|
|
375
|
+
}
|
|
376
|
+
const version = createHash("sha256").update(raw).digest("hex").slice(0, 16);
|
|
377
|
+
let parsed;
|
|
378
|
+
try {
|
|
379
|
+
parsed = parse(raw);
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
return { data: null, version, error: `failed to parse ${configPath}: ${err.message}` };
|
|
383
|
+
}
|
|
384
|
+
// Empty / comments-only file → no overrides, same reading as loadConfigFile.
|
|
385
|
+
if (parsed === null || parsed === undefined)
|
|
386
|
+
return { data: {}, version };
|
|
387
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
388
|
+
return { data: null, version, error: `${configPath} is not a YAML mapping` };
|
|
389
|
+
}
|
|
390
|
+
return { data: parsed, version };
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The current fingerprint of the config file (`null` when absent) — the PUT
|
|
394
|
+
* path's half of the optimistic-concurrency check. Deliberately re-reads rather
|
|
395
|
+
* than trusting a cached value: the point is to notice a write we did not make.
|
|
396
|
+
*/
|
|
397
|
+
export function instanceConfigVersion(configPath) {
|
|
398
|
+
return readConfigFileSnapshot(configPath).version;
|
|
399
|
+
}
|
|
400
|
+
/** Structural equality for the value shapes a field can hold (scalars + string lists). */
|
|
401
|
+
function valuesEqual(a, b) {
|
|
402
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
403
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
404
|
+
return false;
|
|
405
|
+
return a.every((v, i) => v === b[i]);
|
|
406
|
+
}
|
|
407
|
+
return a === b;
|
|
408
|
+
}
|
|
293
409
|
/**
|
|
294
410
|
* Default filename for the instance-config file, resolved under the data dir.
|
|
295
411
|
* Kept in sync with config.ts's private constant (both point at the same file);
|
|
@@ -310,9 +426,25 @@ export function instanceConfigPath(cfg) {
|
|
|
310
426
|
return path.join(cfg.dataDir, DEFAULT_CONFIG_FILENAME);
|
|
311
427
|
}
|
|
312
428
|
/**
|
|
313
|
-
* Build the grouped GET DTO from the resolved (frozen) config
|
|
314
|
-
*
|
|
315
|
-
*
|
|
429
|
+
* Build the grouped GET DTO from the resolved (frozen) config, the live env AND
|
|
430
|
+
* the config file on disk. Each field reports:
|
|
431
|
+
*
|
|
432
|
+
* - `value` — EFFECTIVE now, out of the frozen boot config;
|
|
433
|
+
* - `pendingValue` — what a restart would resolve, out of the file this instant;
|
|
434
|
+
* - `pendingRestart` — those two differ.
|
|
435
|
+
*
|
|
436
|
+
* Reading the file is the load-bearing part (#722). While this built the DTO
|
|
437
|
+
* from the frozen config alone, `GET` could not observe ANY write — including
|
|
438
|
+
* the one the caller had just made — so a successful save appeared to revert,
|
|
439
|
+
* two tabs clobbered each other with nothing able to notice, and
|
|
440
|
+
* `restartRequired` had no choice but to be a hardcoded `false`.
|
|
441
|
+
*
|
|
442
|
+
* Pending values are computed for EDITABLE, non-env-shadowed fields only:
|
|
443
|
+
* - an env-shadowed field resolves to the same env value after a restart, so
|
|
444
|
+
* the file cannot make it diverge;
|
|
445
|
+
* - the read-only `advanced` bindings are normalised at boot (`dataDir` and
|
|
446
|
+
* friends are canonicalised, `port` is `Number()`-ed), so comparing them
|
|
447
|
+
* against raw file text would manufacture divergence that isn't there.
|
|
316
448
|
*/
|
|
317
449
|
export function buildInstanceConfig(cfg) {
|
|
318
450
|
const groups = GROUPS.map((g) => ({
|
|
@@ -322,9 +454,31 @@ export function buildInstanceConfig(cfg) {
|
|
|
322
454
|
fields: [],
|
|
323
455
|
}));
|
|
324
456
|
const groupById = new Map(groups.map((g) => [g.id, g]));
|
|
457
|
+
const configPath = instanceConfigPath(cfg);
|
|
458
|
+
const file = readConfigFileSnapshot(configPath);
|
|
459
|
+
let restartRequired = false;
|
|
325
460
|
for (const f of FIELDS) {
|
|
326
461
|
const shadow = envOverride(f.envVars, f.envShadowWhenDefined);
|
|
327
462
|
const raw = readPath(cfg, f.key);
|
|
463
|
+
// `undefined` (e.g. sweepMinIntervalMs unset, optional endpoint) → null so
|
|
464
|
+
// it JSON-serializes as an explicit absence rather than dropping the key.
|
|
465
|
+
const value = raw === undefined ? null : raw;
|
|
466
|
+
let pendingValue = value;
|
|
467
|
+
let pendingRestart = false;
|
|
468
|
+
if (f.editable && shadow === undefined && file.data) {
|
|
469
|
+
// Absent, or present-but-null (`debounceMs:` with nothing after it), both
|
|
470
|
+
// mean "no override" — `fileOr`/`fileOpt` degrade a null to the default.
|
|
471
|
+
const fileRaw = readPath(file.data, f.key);
|
|
472
|
+
pendingValue = fileRaw == null ? (f.default ?? null) : coerceForDisplay(f, fileRaw);
|
|
473
|
+
// A field the frozen config leaves unset IS its built-in default (that is
|
|
474
|
+
// what `default` documents), so normalise before comparing — otherwise
|
|
475
|
+
// e.g. `models`, unset and therefore null, would read as forever diverging
|
|
476
|
+
// from the catalog list the file's absence implies.
|
|
477
|
+
const effective = value === null ? (f.default ?? null) : value;
|
|
478
|
+
pendingRestart = !valuesEqual(pendingValue, effective);
|
|
479
|
+
if (pendingRestart)
|
|
480
|
+
restartRequired = true;
|
|
481
|
+
}
|
|
328
482
|
const dto = {
|
|
329
483
|
key: f.key,
|
|
330
484
|
group: f.group,
|
|
@@ -332,9 +486,9 @@ export function buildInstanceConfig(cfg) {
|
|
|
332
486
|
help: f.help,
|
|
333
487
|
type: f.type,
|
|
334
488
|
enumValues: f.enumValues,
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
489
|
+
value,
|
|
490
|
+
pendingValue,
|
|
491
|
+
pendingRestart,
|
|
338
492
|
default: f.default,
|
|
339
493
|
editable: f.editable,
|
|
340
494
|
sensitive: f.sensitive ?? false,
|
|
@@ -343,7 +497,26 @@ export function buildInstanceConfig(cfg) {
|
|
|
343
497
|
};
|
|
344
498
|
groupById.get(f.group)?.fields.push(dto);
|
|
345
499
|
}
|
|
346
|
-
return {
|
|
500
|
+
return {
|
|
501
|
+
groups,
|
|
502
|
+
configPath,
|
|
503
|
+
restartRequired,
|
|
504
|
+
configVersion: file.version,
|
|
505
|
+
...(file.error ? { configFileError: file.error } : {}),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Normalise a value read back out of the file into the shape the writer would
|
|
510
|
+
* have produced, so a hand-typed `"1234"` doesn't read as diverging from a
|
|
511
|
+
* written `1234`. A value the field's own validator rejects is reported
|
|
512
|
+
* verbatim: the file really does say that, and the screen showing it is how an
|
|
513
|
+
* operator finds out (the boot loader would degrade it to the default).
|
|
514
|
+
*/
|
|
515
|
+
function coerceForDisplay(f, raw) {
|
|
516
|
+
if (!f.coerce)
|
|
517
|
+
return raw;
|
|
518
|
+
const res = f.coerce(raw);
|
|
519
|
+
return res.ok ? res.value : raw;
|
|
347
520
|
}
|
|
348
521
|
/** A rejected PUT: which field failed and why (surfaced as a 400). */
|
|
349
522
|
export class InstanceConfigError extends Error {
|
|
@@ -359,6 +532,7 @@ export class InstanceConfigError extends Error {
|
|
|
359
532
|
* {@link InstanceConfigError} on the first offending field:
|
|
360
533
|
* - an unknown key,
|
|
361
534
|
* - a read-only / non-editable key,
|
|
535
|
+
* - a key an env var currently shadows,
|
|
362
536
|
* - a value the field's `coerce` rejects.
|
|
363
537
|
* Returns the coerced `{ key, value }` pairs ready to write. A field whose value
|
|
364
538
|
* is `null` (an editable optional being cleared) is returned with `value: null`
|
|
@@ -373,6 +547,14 @@ export function validatePatch(patch) {
|
|
|
373
547
|
if (!spec.editable || !spec.coerce) {
|
|
374
548
|
throw new InstanceConfigError(`Setting is read-only: ${key}`, key);
|
|
375
549
|
}
|
|
550
|
+
// `env > file > default`: writing a shadowed field to the file changes
|
|
551
|
+
// nothing an operator can observe, now or after a restart. The UI already
|
|
552
|
+
// renders these read-only; without this check the API happily returned 200 +
|
|
553
|
+
// `restartRequired: true` for a write that could never take effect.
|
|
554
|
+
const shadow = envOverride(spec.envVars, spec.envShadowWhenDefined);
|
|
555
|
+
if (shadow !== undefined) {
|
|
556
|
+
throw new InstanceConfigError(`${spec.label} (${key}) is set by the environment variable ${shadow}, which wins over the config file — change it there instead`, key);
|
|
557
|
+
}
|
|
376
558
|
const res = spec.coerce(raw);
|
|
377
559
|
if (!res.ok)
|
|
378
560
|
throw new InstanceConfigError(`${spec.label} (${key}) ${res.error}`, key);
|