@edspencer/paddock 0.65.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/packages/server/dist/cli/args.d.ts +4 -4
- package/packages/server/dist/cli/args.js +5 -5
- package/packages/server/dist/cli/paddock.js +1 -1
- package/packages/server/dist/config.d.ts +7 -0
- package/packages/server/dist/config.js +9 -1
- package/packages/server/dist/herdctl.d.ts +89 -0
- package/packages/server/dist/herdctl.js +169 -2
- package/packages/server/dist/instance-config.js +12 -1
- package/packages/server/dist/projects.d.ts +11 -0
- package/packages/server/dist/projects.js +40 -2
- package/packages/server/dist/routes/chats.js +77 -6
- package/packages/server/dist/routes/projects.js +22 -2
- package/packages/server/dist/schema-version.d.ts +111 -0
- package/packages/server/dist/schema-version.js +142 -0
- package/packages/server/dist/subagents.d.ts +65 -0
- package/packages/server/dist/subagents.js +159 -19
- package/packages/server/dist/tooldetails.d.ts +10 -4
- package/packages/server/dist/tooldetails.js +23 -6
- package/packages/server/dist/trusted-proxy.js +1 -1
- package/packages/server/dist/turn-interlock.d.ts +101 -0
- package/packages/server/dist/turn-interlock.js +76 -0
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DBrwtZjx.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-Ri1LwAUN.js → MarkdownRenderer-Bn6VPBdL.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-Dze5H8Xy.js → ProjectRedirect-CQmDBtd-.js} +1 -1
- package/packages/web/dist/assets/ProjectView-BVKu9oIg.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-BidsM7ah.js → ProjectsGrid-BW56zqke.js} +1 -1
- package/packages/web/dist/assets/{arc-eQ3ryE9c.js → arc-CGy6WkLl.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Dc6Tj2Ai.js → architectureDiagram-3BPJPVTR-DJH1WIxY.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DBX5pma_.js → blockDiagram-GPEHLZMM-Cufb_pvX.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CnWrtTpa.js → c4Diagram-AAUBKEIU-glqeg1Ky.js} +1 -1
- package/packages/web/dist/assets/channel-C4kPYn_d.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-2qmLvtsr.js → chunk-2J33WTMH-SOFTHYgh.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-Dn-LUDMV.js → chunk-4BX2VUAB-DFxFAiG8.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-DB0augUY.js → chunk-55IACEB6-CEEDxZVG.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-BPQQM0i8.js → chunk-727SXJPM--3eiptsh.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DOhfwA-x.js → chunk-AQP2D5EJ-DIHIugCo.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-CatHHdFD.js → chunk-FMBD7UC4-BmxWZl5c.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-B645aAlG.js → chunk-ND2GUHAM-hMHtJs_I.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-D1fAaPSZ.js → chunk-QZHKN3VN-CRnnasyy.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCg6gTVh.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCg6gTVh.js +2 -0
- package/packages/web/dist/assets/{core-B4t7D8Om.js → core-CGW2CVAY.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BLdKDAwa.js → cose-bilkent-S5V4N54A-kcl9qSfK.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-CbTbTVKv.js → dagre-BM42HDAG-BiLsFx1W.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-CorSS29_.js → diagram-2AECGRRQ-BcIpoY66.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-PLKivts0.js → diagram-5GNKFQAL-BK5p5kJa.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-CUSIBacZ.js → diagram-KO2AKTUF-BmJYalCP.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CZ69mAZs.js → diagram-LMA3HP47-BHs0SWEM.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-Cmf042Rh.js → diagram-OG6HWLK6-CSs8hie4.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-DyIKGj6e.js → erDiagram-TEJ5UH35-4qd_Td27.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-B79pLdLD.js → flowDiagram-I6XJVG4X-CstUgr7V.js} +1 -1
- package/packages/web/dist/assets/{format-C5nhFL8q.js → format-B_kAvAW3.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-BFTR_oNA.js → ganttDiagram-6RSMTGT7-DTn9OuIc.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DdGUlJxS.js → gitGraphDiagram-PVQCEYII-E_bCor2O.js} +1 -1
- package/packages/web/dist/assets/{index-DVSsta3B.js → index-B-QAOq9a.js} +2 -2
- package/packages/web/dist/assets/index-B3K5nsLe.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-B80FyUOy.js → infoDiagram-5YYISTIA-foRUrIOQ.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-_wRlphn9.js → ishikawaDiagram-YF4QCWOH-CTON8v5t.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-qu4yvjW8.js → journeyDiagram-JHISSGLW-D-qwGjr7.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-D2DEUp2-.js → kanban-definition-UN3LZRKU-5l4y83rE.js} +1 -1
- package/packages/web/dist/assets/{linear-D-CLRUEW.js → linear-B1GiUWpI.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-DdihBJl4.js → mermaid.core-btQIOU6d.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BEh_TDBf.js → mindmap-definition-RKZ34NQL-DduczfMv.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BbrmLVaZ.js → pieDiagram-4H26LBE5-D86uGusc.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-DfVuygAv.js → quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BHp2QpZt.js → requirementDiagram-4Y6WPE33-C9aZJK93.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D2G6lRMY.js → sankeyDiagram-5OEKKPKP-b__W6SAJ.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Dj-jzR-c.js → sequenceDiagram-3UESZ5HK-Lr4Df8kX.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-C5D6bT13.js → stateDiagram-AJRCARHV-R0XxGxD5.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-i2pZN-iR.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DE4gGjhP.js → timeline-definition-PNZ67QCA-D42Je9LW.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-C9QNrl92.js → vennDiagram-CIIHVFJN-KCu14-QB.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-U2fSGy-d.js → wardley-L42UT6IY-Cfir3V9o.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-B9RhObdH.js → wardleyDiagram-YWT4CUSO-BkXjbTXu.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BnlPrtQ9.js → xychartDiagram-2RQKCTM6-BVT60Pln.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DcprTH2W.js +0 -2
- package/packages/web/dist/assets/ProjectView-PrA8purE.js +0 -13
- package/packages/web/dist/assets/channel-B4sED4OP.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BlVeHFir.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BlVeHFir.js +0 -2
- package/packages/web/dist/assets/index-B6etJCHf.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-C8NDOqTq.js +0 -2
|
@@ -88,7 +88,7 @@ export const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
|
88
88
|
*/
|
|
89
89
|
export const MTIME_CACHE_MAX = 1024;
|
|
90
90
|
const taskUsesCache = new Map();
|
|
91
|
-
const
|
|
91
|
+
const factsCache = new Map();
|
|
92
92
|
const usageCache = new Map();
|
|
93
93
|
/** The file's mtime in epoch ms, or undefined if it can't be stat'd. */
|
|
94
94
|
export async function statMtimeMs(file) {
|
|
@@ -137,7 +137,7 @@ export async function readTaskToolUses(projectDir, sessionId) {
|
|
|
137
137
|
*/
|
|
138
138
|
async function readTaskUsesForSession(projectDir, sessionId) {
|
|
139
139
|
if (!SAFE_SEGMENT.test(sessionId))
|
|
140
|
-
return
|
|
140
|
+
return emptyTaskToolUses();
|
|
141
141
|
return readTaskUsesFromFile(path.join(projectChatsDir(projectDir), `${sessionId}.jsonl`));
|
|
142
142
|
}
|
|
143
143
|
/**
|
|
@@ -158,10 +158,32 @@ async function readTaskUsesFromFile(file) {
|
|
|
158
158
|
mtimeCacheSet(taskUsesCache, file, mtimeMs, value);
|
|
159
159
|
return value;
|
|
160
160
|
}
|
|
161
|
+
function emptyTaskToolUses() {
|
|
162
|
+
return { paired: [], pending: [], sidechain: { uuids: new Set(), taskIds: new Set() } };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* True when a raw transcript LINE was written on a sub-agent's own lane.
|
|
166
|
+
*
|
|
167
|
+
* Two markers, because the writers disagree: Claude Code stamps `isSidechain:
|
|
168
|
+
* true` (plus an `agentId`) on the line, while the SDK message stream — which
|
|
169
|
+
* `isSidechainMessage` in `ws-turn.ts` reads live, and which the test harness
|
|
170
|
+
* mirrors onto disk — carries `parent_tool_use_id`. Either is conclusive.
|
|
171
|
+
*
|
|
172
|
+
* Exported because EVERY file-order join over a main transcript has to agree with
|
|
173
|
+
* {@link dropSidechainMessages} about which lines exist: a scan that keeps a
|
|
174
|
+
* sidechain line while the parsed message list has dropped it shifts the whole
|
|
175
|
+
* join by one (#727).
|
|
176
|
+
*/
|
|
177
|
+
export function isSidechainLine(parsed) {
|
|
178
|
+
if (parsed.isSidechain === true)
|
|
179
|
+
return true;
|
|
180
|
+
return Boolean(parsed.parent_tool_use_id ?? parsed.message?.parent_tool_use_id);
|
|
181
|
+
}
|
|
161
182
|
async function readTaskUsesFromFileUncached(file) {
|
|
162
183
|
const byId = new Map();
|
|
163
184
|
const order = [];
|
|
164
185
|
const resultIds = new Set();
|
|
186
|
+
const sidechain = { uuids: new Set(), taskIds: new Set() };
|
|
165
187
|
const stream = createReadStream(file, { encoding: "utf8" });
|
|
166
188
|
stream.on("error", () => undefined);
|
|
167
189
|
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
@@ -177,12 +199,20 @@ async function readTaskUsesFromFileUncached(file) {
|
|
|
177
199
|
catch {
|
|
178
200
|
continue;
|
|
179
201
|
}
|
|
202
|
+
// A sub-agent's OWN transcript is sidechain top to bottom, so this only ever
|
|
203
|
+
// marks anything on a MAIN session file — which is the only place the
|
|
204
|
+
// distinction means something (issue #727).
|
|
205
|
+
const onSidechain = isSidechainLine(parsed);
|
|
206
|
+
if (onSidechain && typeof parsed.uuid === "string")
|
|
207
|
+
sidechain.uuids.add(parsed.uuid);
|
|
180
208
|
const content = parsed.message?.content;
|
|
181
209
|
if (!Array.isArray(content))
|
|
182
210
|
continue;
|
|
183
211
|
for (const block of content) {
|
|
184
212
|
const b = block;
|
|
185
213
|
if (b?.type === "tool_use" && b.name && SUBAGENT_TOOL_NAMES.has(b.name) && b.id) {
|
|
214
|
+
if (onSidechain)
|
|
215
|
+
sidechain.taskIds.add(b.id);
|
|
186
216
|
if (!byId.has(b.id)) {
|
|
187
217
|
order.push(b.id);
|
|
188
218
|
byId.set(b.id, {
|
|
@@ -200,7 +230,7 @@ async function readTaskUsesFromFileUncached(file) {
|
|
|
200
230
|
}
|
|
201
231
|
}
|
|
202
232
|
catch {
|
|
203
|
-
return
|
|
233
|
+
return emptyTaskToolUses();
|
|
204
234
|
}
|
|
205
235
|
finally {
|
|
206
236
|
rl.close();
|
|
@@ -209,8 +239,43 @@ async function readTaskUsesFromFileUncached(file) {
|
|
|
209
239
|
return {
|
|
210
240
|
paired: order.filter((id) => resultIds.has(id)).map((id) => byId.get(id)),
|
|
211
241
|
pending: order.filter((id) => !resultIds.has(id)).map((id) => byId.get(id)),
|
|
242
|
+
sidechain,
|
|
212
243
|
};
|
|
213
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Drop a session's sub-agent sidechain steps from its parsed history (issue
|
|
247
|
+
* #727) — the history-path counterpart of the live path's `isSidechainMessage`
|
|
248
|
+
* filter, and the same invariant: a sub-agent's steps render INSIDE its Task
|
|
249
|
+
* card (via the subagents endpoint), never as top-level rows of the parent.
|
|
250
|
+
*
|
|
251
|
+
* Joins on `uuid`, the source line's own id, which `@herdctl/core` preserves on
|
|
252
|
+
* every parsed message (and, for a paired tool message, takes from the
|
|
253
|
+
* originating `tool_use` entry — so a sidechain tool call matches on the same
|
|
254
|
+
* line that made it sidechain). Rides the mtime-cached transcript scan, and
|
|
255
|
+
* short-circuits to the identity for the overwhelmingly common transcript with
|
|
256
|
+
* no sidechain lines at all, so this costs nothing on the normal path.
|
|
257
|
+
*
|
|
258
|
+
* A transcript that is sidechain top to bottom is not a parent with leaked steps
|
|
259
|
+
* — it IS a sub-agent's own session, the shape Claude Code writes as its own
|
|
260
|
+
* `agent-<hex>.jsonl`. herdctl keeps those out of session discovery, but if one
|
|
261
|
+
* is ever reached directly it should render its content, not an empty chat, so
|
|
262
|
+
* a drop that would empty the history is refused.
|
|
263
|
+
*/
|
|
264
|
+
export async function dropSidechainMessages(projectDir, sessionId, messages) {
|
|
265
|
+
const { sidechain } = await readTaskUsesForSession(projectDir, sessionId);
|
|
266
|
+
if (sidechain.uuids.size === 0)
|
|
267
|
+
return messages;
|
|
268
|
+
const kept = messages.filter((m) => !(m.uuid && sidechain.uuids.has(m.uuid)));
|
|
269
|
+
return kept.length === 0 ? messages : kept;
|
|
270
|
+
}
|
|
271
|
+
/** The same launches minus any a sub-agent made on its own sidechain lane. */
|
|
272
|
+
function withoutSidechainLaunches(uses) {
|
|
273
|
+
const { taskIds } = uses.sidechain;
|
|
274
|
+
if (taskIds.size === 0)
|
|
275
|
+
return uses;
|
|
276
|
+
const keep = (u) => !taskIds.has(u.toolUseId);
|
|
277
|
+
return { ...uses, paired: uses.paired.filter(keep), pending: uses.pending.filter(keep) };
|
|
278
|
+
}
|
|
214
279
|
/**
|
|
215
280
|
* Live sub-agent enrichment for a tool frame (issue #429). When the tool is a
|
|
216
281
|
* `Task`/`Agent` launch this turn already recovered (via {@link extractSubagentLaunches}),
|
|
@@ -306,10 +371,16 @@ export async function readSubagentMessages(projectDir, sessionId, toolUseId) {
|
|
|
306
371
|
export async function enrichWithSubagents(projectDir, sessionId, messages) {
|
|
307
372
|
if (!hasSubagentTool(messages))
|
|
308
373
|
return messages;
|
|
309
|
-
const [
|
|
374
|
+
const [rawTaskUses, subagents] = await Promise.all([
|
|
310
375
|
readTaskUsesForSession(projectDir, sessionId),
|
|
311
376
|
listSubagents(projectDir, sessionId),
|
|
312
377
|
]);
|
|
378
|
+
// The join is by FILE ORDER, so it has to see the same launches the caller's
|
|
379
|
+
// message list does. {@link dropSidechainMessages} has already removed the
|
|
380
|
+
// sidechain rows from that list, so a nested launch a sub-agent made on its own
|
|
381
|
+
// lane must come out of the cursor too — leaving it in would shift every
|
|
382
|
+
// following top-level sub-agent's enrichment onto the wrong card (#727).
|
|
383
|
+
const taskUses = withoutSidechainLaunches(rawTaskUses);
|
|
313
384
|
const [durations, costs] = await Promise.all([
|
|
314
385
|
subagentDurations(subagents),
|
|
315
386
|
subagentCosts(subagents),
|
|
@@ -317,42 +388,85 @@ export async function enrichWithSubagents(projectDir, sessionId, messages) {
|
|
|
317
388
|
return attachSubagentFields(messages, taskUses, subagents, durations, costs);
|
|
318
389
|
}
|
|
319
390
|
/**
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
391
|
+
* How long a sub-agent transcript with no terminal `end_turn` may stay quiet
|
|
392
|
+
* before we stop calling it live (issue #725).
|
|
393
|
+
*
|
|
394
|
+
* A sub-agent that was interrupted — its keeper killed, the box restarted — never
|
|
395
|
+
* writes an `end_turn`, so without this fallback its card would advertise "still
|
|
396
|
+
* running" forever and its duration would never appear. The window has to be
|
|
397
|
+
* comfortably longer than the longest gap a WORKING sub-agent can leave between
|
|
398
|
+
* transcript lines, which is one slow tool call (a full test suite, a long build),
|
|
399
|
+
* not one slow model response. Ten minutes clears that with room to spare, and the
|
|
400
|
+
* `end_turn` check below means the overwhelming majority of finished sub-agents
|
|
401
|
+
* (392 of the 483 real transcripts this was measured against) never wait for it at
|
|
402
|
+
* all — they are recognised as finished the instant their last line lands.
|
|
403
|
+
*/
|
|
404
|
+
const SUBAGENT_STALE_MS = 10 * 60_000;
|
|
405
|
+
/**
|
|
406
|
+
* Compute each FINISHED sub-agent's run time (ms) from the first→last timestamp
|
|
407
|
+
* in its own transcript. Keyed by toolUseId. Runs once per session on chat open
|
|
408
|
+
* (only over that session's sub-agent files), so the cost is bounded to the open
|
|
409
|
+
* chat.
|
|
410
|
+
*
|
|
411
|
+
* A sub-agent that still looks live is deliberately left OUT of the map (issue
|
|
412
|
+
* #725). `subagentDurationMs` is not merely cosmetic — it is the client's
|
|
413
|
+
* finished signal (`useRunningSubagents` drops any card that carries one) — and
|
|
414
|
+
* it is derived from a transcript that is still growing, so publishing it for a
|
|
415
|
+
* running sub-agent both freezes a bogus part-way number onto the card and
|
|
416
|
+
* evicts it from the running-sub-agents bar for the rest of the run. #622 fixed
|
|
417
|
+
* that for the `pending` branch of {@link attachSubagentFields} by omitting the
|
|
418
|
+
* field; the `paired` branch below it kept stamping one, and because the SDK
|
|
419
|
+
* backgrounds sub-agents, the launching `Task` pairs within milliseconds while
|
|
420
|
+
* the sub-agent keeps working — so a live sub-agent reaches the paired branch as
|
|
421
|
+
* a matter of course. Gating at the source fixes both branches by construction.
|
|
323
422
|
*/
|
|
324
423
|
async function subagentDurations(subagents) {
|
|
325
424
|
const out = new Map();
|
|
425
|
+
const now = Date.now();
|
|
326
426
|
await Promise.all([...subagents.values()].map(async (m) => {
|
|
327
|
-
const ms = await
|
|
427
|
+
const ms = await readFinishedSubagentDurationMs(m.transcriptPath, now);
|
|
328
428
|
if (ms !== undefined)
|
|
329
429
|
out.set(m.toolUseId, ms);
|
|
330
430
|
}));
|
|
331
431
|
return out;
|
|
332
432
|
}
|
|
333
433
|
/**
|
|
334
|
-
* The elapsed wall-clock of a sub-agent transcript
|
|
335
|
-
*
|
|
336
|
-
* undefined if the file is missing or has < 2 timestamps.
|
|
434
|
+
* The elapsed wall-clock of a FINISHED sub-agent transcript, or undefined when it
|
|
435
|
+
* is missing, has < 2 timestamps, or still looks live (see {@link SUBAGENT_STALE_MS}).
|
|
337
436
|
*/
|
|
338
|
-
async function
|
|
437
|
+
async function readFinishedSubagentDurationMs(file, now) {
|
|
339
438
|
const mtimeMs = await statMtimeMs(file);
|
|
439
|
+
const facts = await readSubagentFacts(file, mtimeMs);
|
|
440
|
+
if (facts.durationMs === undefined)
|
|
441
|
+
return undefined;
|
|
442
|
+
// No mtime (the file vanished between the stat and the read) ⇒ nothing is
|
|
443
|
+
// writing to it, so fall back to the structural check alone.
|
|
444
|
+
const quietMs = mtimeMs === undefined ? Infinity : now - mtimeMs;
|
|
445
|
+
if (!facts.endsWithEndTurn && quietMs < SUBAGENT_STALE_MS)
|
|
446
|
+
return undefined;
|
|
447
|
+
return facts.durationMs;
|
|
448
|
+
}
|
|
449
|
+
async function readSubagentFacts(file, mtimeMs) {
|
|
340
450
|
if (mtimeMs !== undefined) {
|
|
341
|
-
const cached = mtimeCacheGet(
|
|
451
|
+
const cached = mtimeCacheGet(factsCache, file, mtimeMs);
|
|
342
452
|
if (cached.hit)
|
|
343
453
|
return cached.value;
|
|
344
454
|
}
|
|
345
|
-
const value = await
|
|
346
|
-
// Cache even
|
|
347
|
-
// duration-less transcript doesn't re-read the whole file every time.
|
|
455
|
+
const value = await readSubagentFactsUncached(file);
|
|
456
|
+
// Cache even the empty result (missing/malformed) so a repeat open of an
|
|
457
|
+
// unchanged, duration-less transcript doesn't re-read the whole file every time.
|
|
348
458
|
if (mtimeMs !== undefined)
|
|
349
|
-
mtimeCacheSet(
|
|
459
|
+
mtimeCacheSet(factsCache, file, mtimeMs, value);
|
|
350
460
|
return value;
|
|
351
461
|
}
|
|
352
|
-
async function
|
|
462
|
+
async function readSubagentFactsUncached(file) {
|
|
353
463
|
const raw = await fs.readFile(file, "utf8").catch(() => null);
|
|
354
464
|
if (raw === null)
|
|
355
|
-
return
|
|
465
|
+
return { endsWithEndTurn: false };
|
|
466
|
+
return { durationMs: transcriptSpanMs(raw), endsWithEndTurn: endsWithEndTurn(raw) };
|
|
467
|
+
}
|
|
468
|
+
/** First→last `timestamp` delta. Scans for timestamps without a full JSON parse. */
|
|
469
|
+
function transcriptSpanMs(raw) {
|
|
356
470
|
const matches = [...raw.matchAll(/"timestamp":"([^"]+)"/g)];
|
|
357
471
|
if (matches.length < 2)
|
|
358
472
|
return undefined;
|
|
@@ -362,6 +476,25 @@ async function readSubagentDurationMsUncached(file) {
|
|
|
362
476
|
return undefined;
|
|
363
477
|
return last - first;
|
|
364
478
|
}
|
|
479
|
+
/** True when the transcript's last non-empty line is an assistant entry that
|
|
480
|
+
* stopped with `end_turn`. Only that line is JSON-parsed. */
|
|
481
|
+
function endsWithEndTurn(raw) {
|
|
482
|
+
const lines = raw.split("\n");
|
|
483
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
484
|
+
const trimmed = lines[i].trim();
|
|
485
|
+
if (!trimmed)
|
|
486
|
+
continue;
|
|
487
|
+
try {
|
|
488
|
+
const parsed = JSON.parse(trimmed);
|
|
489
|
+
return parsed.type === "assistant" && parsed.message?.stop_reason === "end_turn";
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
// A torn final line means the writer is mid-append — definitively not settled.
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
365
498
|
/**
|
|
366
499
|
* Compute each sub-agent's estimated API-rate cost (USD), keyed by toolUseId, as
|
|
367
500
|
* the RECURSIVE total of the sub-agent PLUS every sub-agent it (transitively)
|
|
@@ -611,6 +744,13 @@ function attachSubagentFields(messages, taskUses, subagents, durations, costs) {
|
|
|
611
744
|
description: use.description,
|
|
612
745
|
prompt: use.prompt,
|
|
613
746
|
hasSubagent: subagents.has(use.toolUseId),
|
|
747
|
+
// Same hazard as the `pending` branch above, and for the same reason:
|
|
748
|
+
// a PAIRED `Task` does NOT mean the sub-agent is done — the SDK
|
|
749
|
+
// backgrounds it, so the tool_result lands in milliseconds while the
|
|
750
|
+
// sub-agent keeps working. `durations` is therefore gated on liveness at
|
|
751
|
+
// its source ({@link subagentDurations}) and holds an entry only for a
|
|
752
|
+
// sub-agent whose transcript has actually settled, so a still-running one
|
|
753
|
+
// gets `undefined` here and keeps its place in the bar (#725).
|
|
614
754
|
subagentDurationMs: durations.get(use.toolUseId),
|
|
615
755
|
subagentCostUsd: costs.get(use.toolUseId),
|
|
616
756
|
},
|
|
@@ -24,10 +24,16 @@ export declare function readToolDetails(projectDir: string, sessionId: string):
|
|
|
24
24
|
*/
|
|
25
25
|
export declare function attachToolDetails(projectDir: string, sessionId: string, messages: EnrichedMessage[]): Promise<EnrichedMessage[]>;
|
|
26
26
|
/**
|
|
27
|
-
* The single history-enrichment orchestrator (issue #237). Composes the
|
|
28
|
-
* server-side passes so the routes call one function:
|
|
29
|
-
* generalized per-tool detail (#237, subsuming #232's
|
|
30
|
-
* task-notification folding (#230) → local-command
|
|
27
|
+
* The single history-enrichment orchestrator (issue #237). Composes the
|
|
28
|
+
* server-side passes so the routes call one function: sidechain drop (#727) →
|
|
29
|
+
* sub-agent recovery (#37) → generalized per-tool detail (#237, subsuming #232's
|
|
30
|
+
* edit diff) → background / task-notification folding (#230) → local-command
|
|
31
|
+
* stdout recovery (#158).
|
|
32
|
+
*
|
|
33
|
+
* The sidechain drop goes FIRST and is the history-path mirror of the live path's
|
|
34
|
+
* `isSidechainMessage` filter: a sub-agent's steps belong inside its Task card,
|
|
35
|
+
* and every later pass here joins by file order, so they have to be gone before
|
|
36
|
+
* anything counts positions.
|
|
31
37
|
*/
|
|
32
38
|
export declare function enrichWithToolDetails(projectDir: string, sessionId: string, messages: EnrichedMessage[]): Promise<EnrichedMessage[]>;
|
|
33
39
|
export {};
|
|
@@ -33,7 +33,7 @@ import { createInterface } from "node:readline";
|
|
|
33
33
|
import path from "node:path";
|
|
34
34
|
import { projectChatsDir } from "./transcripts.js";
|
|
35
35
|
import { fileKind } from "./projects.js";
|
|
36
|
-
import { enrichWithSubagents } from "./subagents.js";
|
|
36
|
+
import { dropSidechainMessages, enrichWithSubagents, isSidechainLine } from "./subagents.js";
|
|
37
37
|
import { enrichWithBackground } from "./background.js";
|
|
38
38
|
import { enrichWithLocalCommands } from "./localcommand.js";
|
|
39
39
|
import { SAFE_SEGMENT, statMtimeMs, mtimeCacheGet, mtimeCacheSet, } from "./subagents.js";
|
|
@@ -355,6 +355,16 @@ async function readToolDetailsFromFileUncached(file) {
|
|
|
355
355
|
catch {
|
|
356
356
|
continue;
|
|
357
357
|
}
|
|
358
|
+
// A sub-agent's steps are not rows of THIS transcript — `dropSidechainMessages`
|
|
359
|
+
// has already removed them from the message list this list joins against, so
|
|
360
|
+
// keeping them would shift every following tool's detail by one (#727). Their
|
|
361
|
+
// detail is derived separately, off the sub-agent's own transcript. (A file
|
|
362
|
+
// that is sidechain top to bottom — a sub-agent's OWN transcript — therefore
|
|
363
|
+
// recovers nothing and its messages pass through unenriched, which is a
|
|
364
|
+
// degrade rather than a misjoin; `dropSidechainMessages` likewise refuses to
|
|
365
|
+
// empty such a history.)
|
|
366
|
+
if (isSidechainLine(parsed))
|
|
367
|
+
continue;
|
|
358
368
|
const content = parsed.message?.content;
|
|
359
369
|
if (!Array.isArray(content))
|
|
360
370
|
continue;
|
|
@@ -453,13 +463,20 @@ export async function attachToolDetails(projectDir, sessionId, messages) {
|
|
|
453
463
|
});
|
|
454
464
|
}
|
|
455
465
|
/**
|
|
456
|
-
* The single history-enrichment orchestrator (issue #237). Composes the
|
|
457
|
-
* server-side passes so the routes call one function:
|
|
458
|
-
* generalized per-tool detail (#237, subsuming #232's
|
|
459
|
-
* task-notification folding (#230) → local-command
|
|
466
|
+
* The single history-enrichment orchestrator (issue #237). Composes the
|
|
467
|
+
* server-side passes so the routes call one function: sidechain drop (#727) →
|
|
468
|
+
* sub-agent recovery (#37) → generalized per-tool detail (#237, subsuming #232's
|
|
469
|
+
* edit diff) → background / task-notification folding (#230) → local-command
|
|
470
|
+
* stdout recovery (#158).
|
|
471
|
+
*
|
|
472
|
+
* The sidechain drop goes FIRST and is the history-path mirror of the live path's
|
|
473
|
+
* `isSidechainMessage` filter: a sub-agent's steps belong inside its Task card,
|
|
474
|
+
* and every later pass here joins by file order, so they have to be gone before
|
|
475
|
+
* anything counts positions.
|
|
460
476
|
*/
|
|
461
477
|
export async function enrichWithToolDetails(projectDir, sessionId, messages) {
|
|
462
|
-
const
|
|
478
|
+
const topLevel = await dropSidechainMessages(projectDir, sessionId, messages);
|
|
479
|
+
const withSubagents = await enrichWithSubagents(projectDir, sessionId, topLevel);
|
|
463
480
|
const withDetails = await attachToolDetails(projectDir, sessionId, withSubagents);
|
|
464
481
|
const withBackground = enrichWithBackground(withDetails);
|
|
465
482
|
return enrichWithLocalCommands(projectDir, sessionId, withBackground);
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* A request from another host to a `0.0.0.0`-published port is SNAT'd to that
|
|
41
41
|
* same address, so "peer is the gateway" does not prove the traffic stayed on
|
|
42
42
|
* the box. Smoke-test from INSIDE the container instead (`docker compose exec
|
|
43
|
-
* paddock curl http://127.0.0.1:
|
|
43
|
+
* paddock curl http://127.0.0.1:7233/mcp …`), where the peer really is loopback.
|
|
44
44
|
*/
|
|
45
45
|
import proxyAddr from "proxy-addr";
|
|
46
46
|
/**
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn interlock (issue #731) — stop a chat's in-flight turn BEFORE a destructive
|
|
3
|
+
* operation mutates its transcript.
|
|
4
|
+
*
|
|
5
|
+
* ## The bug this exists to make impossible
|
|
6
|
+
*
|
|
7
|
+
* A chat's transcript is not something paddock writes: `claude` writes
|
|
8
|
+
* `<sessionId>.jsonl` itself, straight through the symlink `ensureProjectAgent`
|
|
9
|
+
* plants (see `transcripts.ts`). So a destructive operation — delete, promote,
|
|
10
|
+
* revert, project delete — is a race against a live subprocess that still holds
|
|
11
|
+
* that path and will append to it whenever it next has something to say.
|
|
12
|
+
*
|
|
13
|
+
* Deleting the file mid-turn therefore did not delete the chat. The unlink
|
|
14
|
+
* landed, the chat left the list, and ~45s later the still-running process
|
|
15
|
+
* re-created the SAME file containing only its own tail: a 3-line transcript
|
|
16
|
+
* opening on an orphan `tool_result` whose `tool_use` no longer existed. The
|
|
17
|
+
* chat came back, named after its raw session id, with every prior turn
|
|
18
|
+
* permanently gone. Promote lost the chat from BOTH projects the same way.
|
|
19
|
+
*
|
|
20
|
+
* ## The guarantee
|
|
21
|
+
*
|
|
22
|
+
* Nothing here tries to out-race the writer, because that race is unwinnable.
|
|
23
|
+
* Instead {@link quiesceSession} refuses to return "go ahead" until the session
|
|
24
|
+
* is verifiably dead — and a caller that cannot get that answer within its
|
|
25
|
+
* timeout must NOT mutate (the routes return 409 instead). A destructive op
|
|
26
|
+
* therefore only ever runs against a transcript no process holds, and a dead
|
|
27
|
+
* session has nothing left to write itself back with.
|
|
28
|
+
*
|
|
29
|
+
* ## Why two liveness signals
|
|
30
|
+
*
|
|
31
|
+
* `hub.isRunning` sees the WS layer's turn, and `herdctl.isSdkSessionLive` sees
|
|
32
|
+
* herdctl's `SessionReaper` — a session held open past its turn (a killed
|
|
33
|
+
* background task's keepAlive, or the re-invocation grace, #397). Either one
|
|
34
|
+
* means a `claude` process may still write. The recovery engine already pairs
|
|
35
|
+
* them for exactly this reason (`ws-turn.ts`); so do we.
|
|
36
|
+
*
|
|
37
|
+
* ## Why cancel is not enough on its own
|
|
38
|
+
*
|
|
39
|
+
* `herdctl.cancel(jobId)` ends the model TURN. On the session runtime that is an
|
|
40
|
+
* `interrupt()`, which deliberately leaves the session — and its subprocess —
|
|
41
|
+
* alive for the next message. That is right for the Stop button and wrong for a
|
|
42
|
+
* delete, so we also reap the managed session. Cancel and reap are both
|
|
43
|
+
* best-effort; the polled liveness check is what actually decides.
|
|
44
|
+
*/
|
|
45
|
+
import type { SessionHub } from "./session-hub.js";
|
|
46
|
+
import type { HerdctlService } from "./herdctl.js";
|
|
47
|
+
/** How long to wait for a cancelled turn to actually die before giving up. */
|
|
48
|
+
export declare const DEFAULT_QUIESCE_TIMEOUT_MS = 10000;
|
|
49
|
+
/**
|
|
50
|
+
* The dependencies the interlock needs. Both come off the route context: `hub`
|
|
51
|
+
* via `ctx.managementOpsContext?.hub` (hence optional — a server built without
|
|
52
|
+
* the WS layer has no hub, and correctly reports nothing running).
|
|
53
|
+
*/
|
|
54
|
+
export interface TurnInterlockDeps {
|
|
55
|
+
hub: SessionHub | undefined;
|
|
56
|
+
herdctl: HerdctlService;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* - `idle` — nothing was running; the caller may proceed.
|
|
60
|
+
* - `stopped` — a turn WAS running, was stopped, and is now verifiably dead;
|
|
61
|
+
* the caller may proceed.
|
|
62
|
+
* - `stuck` — something is still alive after the timeout. The caller MUST NOT
|
|
63
|
+
* mutate the transcript: that is precisely the resurrection case.
|
|
64
|
+
*/
|
|
65
|
+
export type QuiesceOutcome = "idle" | "stopped" | "stuck";
|
|
66
|
+
/**
|
|
67
|
+
* Is anything alive on this session right now — a live WS turn, or a session
|
|
68
|
+
* herdctl's reaper is still holding open?
|
|
69
|
+
*/
|
|
70
|
+
export declare function sessionIsBusy(deps: TurnInterlockDeps, sessionId: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Stop whatever is running against `sessionId` and WAIT for it to actually be
|
|
73
|
+
* gone. Returns `stuck` rather than throwing, so each caller can pick its own
|
|
74
|
+
* failure shape (a 409, or a `failed` bucket entry in a batch).
|
|
75
|
+
*
|
|
76
|
+
* A running turn is not always cancellable the instant we look at it: `jobId`
|
|
77
|
+
* is null early in a turn (it arrives with the first frames). That is why this
|
|
78
|
+
* polls rather than cancelling once — each pass re-reads `activeInfo` and
|
|
79
|
+
* cancels any job id it has not cancelled yet, so a turn that arms its job id a
|
|
80
|
+
* beat later is still caught inside the same call.
|
|
81
|
+
*/
|
|
82
|
+
export declare function quiesceSession(deps: TurnInterlockDeps, sessionId: string, opts?: {
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
}): Promise<QuiesceOutcome>;
|
|
85
|
+
/**
|
|
86
|
+
* Quiesce every session with a running turn in `projectSlug`, ahead of a project
|
|
87
|
+
* delete. Returns the ids that could NOT be stopped — empty means it is safe to
|
|
88
|
+
* remove the project directory.
|
|
89
|
+
*
|
|
90
|
+
* Only the hub can enumerate running turns by project, so a server with no hub
|
|
91
|
+
* simply has nothing to stop.
|
|
92
|
+
*/
|
|
93
|
+
export declare function quiesceProject(deps: TurnInterlockDeps, projectSlug: string, opts?: {
|
|
94
|
+
timeoutMs?: number;
|
|
95
|
+
}): Promise<string[]>;
|
|
96
|
+
/** The 409 body every refusing route sends, so clients can match one code. */
|
|
97
|
+
export declare function turnRunningError(sessionIds: string[]): {
|
|
98
|
+
error: string;
|
|
99
|
+
code: "turn_running";
|
|
100
|
+
sessionIds: string[];
|
|
101
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** How long to wait for a cancelled turn to actually die before giving up. */
|
|
2
|
+
export const DEFAULT_QUIESCE_TIMEOUT_MS = 10_000;
|
|
3
|
+
/** How often to re-check liveness while waiting. */
|
|
4
|
+
const POLL_MS = 25;
|
|
5
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
|
+
/**
|
|
7
|
+
* Is anything alive on this session right now — a live WS turn, or a session
|
|
8
|
+
* herdctl's reaper is still holding open?
|
|
9
|
+
*/
|
|
10
|
+
export function sessionIsBusy(deps, sessionId) {
|
|
11
|
+
if (deps.hub?.isRunning(sessionId) === true)
|
|
12
|
+
return true;
|
|
13
|
+
return deps.herdctl.isSdkSessionLive(sessionId);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Stop whatever is running against `sessionId` and WAIT for it to actually be
|
|
17
|
+
* gone. Returns `stuck` rather than throwing, so each caller can pick its own
|
|
18
|
+
* failure shape (a 409, or a `failed` bucket entry in a batch).
|
|
19
|
+
*
|
|
20
|
+
* A running turn is not always cancellable the instant we look at it: `jobId`
|
|
21
|
+
* is null early in a turn (it arrives with the first frames). That is why this
|
|
22
|
+
* polls rather than cancelling once — each pass re-reads `activeInfo` and
|
|
23
|
+
* cancels any job id it has not cancelled yet, so a turn that arms its job id a
|
|
24
|
+
* beat later is still caught inside the same call.
|
|
25
|
+
*/
|
|
26
|
+
export async function quiesceSession(deps, sessionId, opts = {}) {
|
|
27
|
+
if (!sessionIsBusy(deps, sessionId))
|
|
28
|
+
return "idle";
|
|
29
|
+
const deadline = Date.now() + (opts.timeoutMs ?? DEFAULT_QUIESCE_TIMEOUT_MS);
|
|
30
|
+
const cancelled = new Set();
|
|
31
|
+
let reaped = false;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const jobId = deps.hub?.activeInfo(sessionId)?.jobId ?? null;
|
|
34
|
+
if (jobId && !cancelled.has(jobId)) {
|
|
35
|
+
cancelled.add(jobId);
|
|
36
|
+
await deps.herdctl.cancel(jobId).catch(() => false);
|
|
37
|
+
}
|
|
38
|
+
// Close the managed session too — on the session runtime `cancel` only
|
|
39
|
+
// interrupts the turn and leaves the subprocess holding the transcript.
|
|
40
|
+
// Once is enough; a second reap of an already-closed session is a no-op.
|
|
41
|
+
if (!reaped) {
|
|
42
|
+
reaped = true;
|
|
43
|
+
deps.herdctl.reapSession(sessionId);
|
|
44
|
+
}
|
|
45
|
+
if (!sessionIsBusy(deps, sessionId))
|
|
46
|
+
return "stopped";
|
|
47
|
+
if (Date.now() >= deadline)
|
|
48
|
+
return "stuck";
|
|
49
|
+
await delay(POLL_MS);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Quiesce every session with a running turn in `projectSlug`, ahead of a project
|
|
54
|
+
* delete. Returns the ids that could NOT be stopped — empty means it is safe to
|
|
55
|
+
* remove the project directory.
|
|
56
|
+
*
|
|
57
|
+
* Only the hub can enumerate running turns by project, so a server with no hub
|
|
58
|
+
* simply has nothing to stop.
|
|
59
|
+
*/
|
|
60
|
+
export async function quiesceProject(deps, projectSlug, opts = {}) {
|
|
61
|
+
const running = (deps.hub?.runningSessions() ?? []).filter((i) => i.projectSlug === projectSlug);
|
|
62
|
+
// Concurrent: each turn dies independently, so the whole call is bounded by one
|
|
63
|
+
// timeout rather than N of them stacked end to end.
|
|
64
|
+
const outcomes = await Promise.all(running.map(async (info) => [info.sessionId, await quiesceSession(deps, info.sessionId, opts)]));
|
|
65
|
+
return outcomes.filter(([, outcome]) => outcome === "stuck").map(([sessionId]) => sessionId);
|
|
66
|
+
}
|
|
67
|
+
/** The 409 body every refusing route sends, so clients can match one code. */
|
|
68
|
+
export function turnRunningError(sessionIds) {
|
|
69
|
+
return {
|
|
70
|
+
error: sessionIds.length === 1
|
|
71
|
+
? `A turn is still running on chat ${sessionIds[0]} and could not be stopped; try again.`
|
|
72
|
+
: `Turns are still running on ${sessionIds.length} chats and could not be stopped; try again.`,
|
|
73
|
+
code: "turn_running",
|
|
74
|
+
sessionIds,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{r as p,k as Q,b as C,j as t,A as B,l as U,X as W,m as Y,n as Z,p as _,M as ee,q as te}from"./index-B-QAOq9a.js";function ne(){const[e,n]=p.useState(null),[l,o]=p.useState(!0),[a,c]=p.useState(null),[i,g]=p.useState({}),[u,O]=p.useState(!1),[$,y]=p.useState(null),[R,w]=p.useState(!1),[k,V]=p.useState(""),[j,P]=p.useState(!1),[D,A]=p.useState(null),F=p.useRef(null),T=Q("(min-width: 1024px)");p.useEffect(()=>{let s=!0;return o(!0),C.getInstanceConfig().then(r=>{s&&(n(r),c(null))}).catch(r=>s&&c(r instanceof Error?r.message:String(r))).finally(()=>s&&o(!1)),()=>{s=!1}},[]);const G=(s,r)=>{w(!1),y(null),g(d=>({...d,[s]:r}))},q=s=>Object.prototype.hasOwnProperty.call(i,s.key)?i[s.key]:s.value,S=p.useMemo(()=>e?e.groups.flatMap(s=>s.fields):[],[e]),x=p.useMemo(()=>S.filter(s=>s.editable&&!s.envOverridden).filter(s=>Object.prototype.hasOwnProperty.call(i,s.key)).filter(s=>!M(i[s.key],s.value)).map(s=>s.key),[S,i]),I=p.useMemo(()=>new Set(x),[x]),m=p.useMemo(()=>{if(!e)return[];const s=k.trim().toLowerCase(),r=[];for(const d of e.groups){const f=s!==""&&d.label.toLowerCase().includes(s),b=d.fields.filter(h=>{const v=Object.prototype.hasOwnProperty.call(i,h.key)?i[h.key]:h.value;return j&&M(v,h.default)?!1:s===""||f?!0:ue(h).includes(s)});b.length>0&&r.push({...d,fields:b})}return r},[e,k,j,i]),L=m.reduce((s,r)=>s+r.fields.length,0),H=k.trim()!==""||j,K=m.some(s=>s.fields.some(r=>r.envOverridden));p.useEffect(()=>{const s=F.current;if(!s||m.length===0)return;const r=m.map(f=>f.id),d=()=>{const f=s.getBoundingClientRect().top;let b=r[0];for(const h of r){const v=document.getElementById(E(h));if(v)if(v.getBoundingClientRect().top-f<=80)b=h;else break}A(b)};return d(),s.addEventListener("scroll",d,{passive:!0}),()=>s.removeEventListener("scroll",d)},[m]);const X=s=>{var d;const r=document.getElementById(E(s));(d=r==null?void 0:r.scrollIntoView)==null||d.call(r,{behavior:"smooth",block:"start"}),A(s)},z=async()=>{if(x.length===0)return;const s={};for(const r of x)s[r]=i[r];O(!0),y(null);try{await C.updateInstanceConfig(s);const r=await C.getInstanceConfig();n(r),g({}),w(!0)}catch(r){y(r instanceof Z||r instanceof Error?r.message:String(r))}finally{O(!1)}},J=()=>{g({}),y(null),w(!1)};return t.jsxs(t.Fragment,{children:[t.jsxs("div",{className:"flex min-h-0 flex-1",children:[e&&t.jsx(se,{groups:m,active:D,onJump:X,dirtyCount:me(m,I)}),t.jsxs("div",{className:"flex min-h-0 min-w-0 flex-1 flex-col",children:[e&&t.jsx(re,{query:k,onQuery:V,modifiedOnly:j,onModifiedOnly:P,visibleCount:L,totalCount:S.length,filtering:H,autoFocus:T}),t.jsx("div",{ref:F,className:"min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6",children:t.jsxs("div",{className:"mx-auto max-w-4xl",children:[t.jsx(ae,{saved:R,configPath:e==null?void 0:e.configPath}),l&&t.jsx("p",{className:"text-sm text-paddock-500",children:"Loading…"}),a&&t.jsxs("p",{className:"text-sm text-red-600 dark:text-red-400",role:"alert",children:["Failed to load settings: ",a]}),e&&K&&t.jsx(le,{}),e&&L===0&&t.jsxs("p",{className:"py-8 text-center text-sm text-paddock-500",children:["No settings match"," ",k.trim()?t.jsxs(t.Fragment,{children:["“",t.jsx("span",{className:"font-medium",children:k.trim()}),"”"]}):"this filter","."]}),m.map((s,r)=>t.jsx(oe,{group:s,first:r===0,children:t.jsx("div",{className:"grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2",children:s.fields.map(d=>t.jsx(ie,{field:d,value:q(d),dirty:I.has(d.key),onChange:f=>G(d.key,f)},d.key))})},s.id))]})})]})]}),e&&t.jsxs("footer",{className:"flex items-center gap-3 border-t border-paddock-200 px-4 py-3 dark:border-paddock-800 sm:px-6",children:[$&&t.jsxs("span",{className:"flex items-center gap-1.5 text-[13px] text-red-600 dark:text-red-400",role:"alert",children:[t.jsx(B,{width:14,height:14,className:"shrink-0"}),$]}),t.jsx("span",{className:"ml-auto text-[12px] text-paddock-400",children:x.length>0?`${x.length} unsaved change${x.length===1?"":"s"}`:"No changes"}),t.jsx("button",{type:"button",className:"btn-subtle",onClick:J,disabled:x.length===0||u,children:"Reset"}),t.jsx("button",{type:"button",className:"btn-primary",onClick:z,disabled:x.length===0||u,children:u?"Saving…":"Save changes"})]})]})}function se({groups:e,active:n,onJump:l,dirtyCount:o}){return t.jsx("nav",{"aria-label":"Config sections",className:"hidden w-52 shrink-0 overflow-y-auto border-r border-paddock-200 px-2 py-4 dark:border-paddock-800 lg:block",children:t.jsx("ul",{className:"space-y-0.5",children:e.map(a=>{const c=a.id===n,i=o[a.id]??0;return t.jsx("li",{children:t.jsxs("button",{type:"button",onClick:()=>l(a.id),"aria-current":c?"true":void 0,className:`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-[13px] transition-colors ${c?"bg-paddock-100 font-medium text-paddock-900 dark:bg-paddock-800 dark:text-paddock-100":"text-paddock-500 hover:bg-paddock-100/60 hover:text-paddock-800 dark:hover:bg-paddock-800/50 dark:hover:text-paddock-200"}`,children:[t.jsx("span",{className:"min-w-0 flex-1 truncate",children:a.label}),i>0&&t.jsx("span",{className:"h-1.5 w-1.5 shrink-0 rounded-full bg-accent","aria-label":`${i} unsaved`}),t.jsx("span",{className:"shrink-0 text-[11px] tabular-nums text-paddock-400",children:a.fields.length})]})},a.id)})})})}function re({query:e,onQuery:n,modifiedOnly:l,onModifiedOnly:o,visibleCount:a,totalCount:c,filtering:i,autoFocus:g}){return t.jsxs("div",{className:"flex flex-wrap items-center gap-2 border-b border-paddock-200 px-4 py-2.5 dark:border-paddock-800 sm:px-6",children:[t.jsxs("div",{className:"relative min-w-0 flex-1 sm:max-w-sm",children:[t.jsx(U,{width:14,height:14,className:"pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-paddock-400"}),t.jsx("input",{type:"search","aria-label":"Search settings",placeholder:"Search settings, keys, env vars…",className:"input h-8 w-full pl-8 pr-7 text-[13px] [&::-webkit-search-cancel-button]:appearance-none",value:e,autoFocus:g,onChange:u=>n(u.target.value),onKeyDown:u=>{u.key==="Escape"&&e!==""&&(u.preventDefault(),n(""))}}),e!==""&&t.jsx("button",{type:"button","aria-label":"Clear search",onClick:()=>n(""),className:"absolute right-2 top-1/2 -translate-y-1/2 text-paddock-400 hover:text-paddock-700 dark:hover:text-paddock-200",children:t.jsx(W,{width:13,height:13})})]}),t.jsx("button",{type:"button","aria-pressed":l,onClick:()=>o(!l),className:`shrink-0 rounded-md border px-2.5 py-1 text-[12px] transition-colors ${l?"border-accent/60 bg-accent/10 text-accent":"border-paddock-200 text-paddock-500 hover:text-paddock-800 dark:border-paddock-700 dark:hover:text-paddock-200"}`,children:"Modified only"}),t.jsx("span",{className:"shrink-0 text-[12px] tabular-nums text-paddock-400",children:i?`${a} of ${c}`:`${c} settings`})]})}function ae({saved:e,configPath:n}){return t.jsxs("div",{role:"status",className:`mb-5 flex items-start gap-2 rounded-lg border px-3 py-2.5 text-[13px] leading-snug ${e?"border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-700/60 dark:bg-emerald-900/20 dark:text-emerald-300":"border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-700/60 dark:bg-amber-900/20 dark:text-amber-300"}`,children:[e?t.jsx(Y,{width:15,height:15,className:"mt-0.5 shrink-0"}):t.jsx(B,{width:15,height:15,className:"mt-0.5 shrink-0"}),t.jsx("span",{children:e?t.jsxs(t.Fragment,{children:[t.jsx("strong",{children:"Saved to disk."})," These changes take effect only after the server restarts."]}):t.jsxs(t.Fragment,{children:["Changes here are written to ",t.jsx("code",{className:"font-mono text-[12px]",children:he(n)})," ","and take effect only after the server restarts — the running instance keeps its current config until then."]})})]})}function le(){return t.jsxs("p",{className:"mb-5 text-[12px] leading-snug text-paddock-500",children:["Settings marked ",t.jsx(N,{tone:"amber",children:"env"})," are overridden by an environment variable, which wins over this file — edit that variable (and restart) to change them."]})}function oe({group:e,first:n,children:l}){return t.jsxs("section",{id:E(e.id),className:`scroll-mt-4 ${n?"":"mt-9 border-t border-paddock-200 pt-7 dark:border-paddock-800"}`,children:[t.jsx("h2",{className:"text-base font-semibold tracking-tight text-paddock-900 dark:text-paddock-50",children:e.label}),e.description&&t.jsx("p",{className:"mt-1 text-[12px] leading-snug text-paddock-500",children:e.description}),t.jsx("div",{className:"mt-4",children:l})]})}function ie({field:e,value:n,dirty:l,onChange:o}){const a=!e.editable||e.envOverridden,c=`cfg-${e.key}`,i=e.type==="boolean";return t.jsxs("div",{className:`min-w-0 ${xe(e,n)?"sm:col-span-2":""} ${l?"-ml-2.5 border-l-2 border-accent pl-2":""}`,children:[t.jsxs("div",{className:"flex items-center justify-between gap-2",children:[t.jsxs("label",{htmlFor:a||i?void 0:c,className:"min-w-0 text-[13px] font-medium",children:[t.jsx("span",{className:"align-middle",children:e.label}),e.sensitive&&t.jsx(N,{tone:"amber",children:"sensitive"}),e.envOverridden&&t.jsx(N,{tone:"amber",title:`Overridden by environment variable ${e.envVar} — edit that env var (and restart) to change it.`,children:"env"}),!e.editable&&!e.envOverridden&&t.jsx(N,{tone:"plain",children:"read-only"})]}),i&&!a&&t.jsx(de,{id:c,checked:!!n,onChange:o,label:e.label})]}),e.help&&t.jsx("p",{className:"mt-0.5 text-[12px] leading-snug text-paddock-500",children:e.help}),a?t.jsx(pe,{field:e,value:n}):!i&&t.jsx(ce,{field:e,value:n,onChange:o,inputId:c}),e.envOverridden&&t.jsx("p",{className:"mt-1 truncate font-mono text-[11px] text-amber-600 dark:text-amber-500/90",children:e.envVar})]})}function N({children:e,tone:n,title:l}){return t.jsx("span",{title:l,className:`ml-1.5 rounded px-1 py-px align-middle text-[10px] font-semibold uppercase tracking-wide ${n==="amber"?"bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400":"bg-paddock-100 text-paddock-500 dark:bg-paddock-800 dark:text-paddock-400"}`,children:e})}function de({id:e,checked:n,onChange:l,label:o}){return t.jsxs("label",{htmlFor:e,className:"relative shrink-0 cursor-pointer",children:[t.jsx("input",{id:e,type:"checkbox","aria-label":o,className:"peer sr-only",checked:n,onChange:a=>l(a.target.checked)}),t.jsx("span",{className:"block h-[18px] w-8 rounded-full bg-paddock-300 transition-colors after:absolute after:left-[2px] after:top-[2px] after:h-[14px] after:w-[14px] after:rounded-full after:bg-white after:transition-transform after:content-[''] peer-checked:bg-accent peer-checked:after:translate-x-[14px] peer-focus-visible:ring-2 peer-focus-visible:ring-accent/50 dark:bg-paddock-700"})]})}function ce({field:e,value:n,onChange:l,inputId:o}){if(e.type==="enum")return t.jsx("select",{id:o,className:"input mt-1.5",value:String(n??""),onChange:a=>l(a.target.value),children:(e.enumValues??[]).map(a=>t.jsx("option",{value:a,children:a},a))});if(e.type==="number")return t.jsx("input",{id:o,type:"number",className:"input mt-1.5",value:n==null?"":String(n),placeholder:e.default===null?"default":String(e.default),onChange:a=>l(a.target.value===""?null:Number(a.target.value))});if(e.type==="text"){const a=n==null||n===e.default,c=n==null?String(e.default??""):String(n),i=typeof e.default=="string"&&!a;return t.jsxs("div",{className:"mt-1.5",children:[t.jsx("textarea",{id:o,className:"input min-h-[7rem] resize-y font-mono text-[12px] leading-relaxed",rows:6,spellCheck:!1,value:c,onChange:g=>l(g.target.value)}),t.jsxs("div",{className:"mt-1 flex items-center justify-between gap-3 text-[12px] text-paddock-500",children:[t.jsx("span",{children:a?"Using the built-in default.":n===""?"Empty — nothing will be appended.":`${c.length.toLocaleString()} characters.`}),i&&t.jsx("button",{type:"button",className:"shrink-0 underline underline-offset-2 hover:text-paddock-700 dark:hover:text-paddock-300",onClick:()=>l(null),children:"Restore default"})]})]})}if(e.type==="string-list"){const a=Array.isArray(n)?n.join(", "):String(n??"");return t.jsx("input",{id:o,type:"text",className:"input mt-1.5",value:a,onChange:c=>l(c.target.value.split(",").map(i=>i.trim()).filter(Boolean))})}return t.jsx("input",{id:o,type:"text",className:"input mt-1.5",value:n==null?"":String(n),placeholder:e.default===null?"":String(e.default),onChange:a=>l(a.target.value)})}function pe({field:e,value:n}){return t.jsx("div",{className:"mt-1 text-[12px] text-paddock-600 dark:text-paddock-300",children:e.type==="boolean"?t.jsx("span",{className:"font-mono",children:n?"true":"false"}):n==null||n===""?t.jsx("span",{className:"italic text-paddock-400",children:e.type==="text"&&n===""?"(empty — nothing appended)":"(not set)"}):e.type==="text"?t.jsx("span",{className:"block max-h-40 overflow-y-auto whitespace-pre-wrap rounded bg-paddock-50 px-2 py-1.5 font-mono text-[12px] leading-relaxed dark:bg-paddock-900/40",children:String(n)}):t.jsx("span",{className:"block break-all font-mono",children:Array.isArray(n)?n.join(", "):String(n)})})}function xe(e,n){return e.type==="text"||e.type==="string-list"?!0:(Array.isArray(n)?n.join(", "):n==null?"":String(n)).length>38}function ue(e){return[e.label,e.key,e.help,e.envVar,...e.enumValues??[]].filter(Boolean).join(" ").toLowerCase()}function me(e,n){const l={};for(const o of e)l[o.id]=o.fields.filter(a=>n.has(a.key)).length;return l}const E=e=>`cfg-section-${e}`;function M(e,n){return Array.isArray(e)&&Array.isArray(n)?e.length===n.length&&e.every((l,o)=>l===n[o]):e===n}function he(e){if(!e)return"paddock.config.yaml";const n=e.split("/");return n[n.length-1]||e}function fe(){const e=_(),n=(e==null?void 0:e.openNav)??(()=>{});return t.jsxs("div",{className:"flex h-full min-h-0 flex-col",children:[t.jsxs("header",{className:"pt-safe flex items-center gap-2 border-b border-paddock-200 px-3 pb-2.5 dark:border-paddock-800 sm:px-6 lg:py-4",children:[t.jsx("button",{type:"button",onClick:n,"aria-label":"Open menu",className:"btn-subtle -ml-1 shrink-0 px-2 py-1.5 lg:hidden",children:t.jsx(ee,{width:20,height:20})}),t.jsx(te,{width:18,height:18,className:"shrink-0 text-paddock-400"}),t.jsx("h1",{className:"text-[15px] font-semibold tracking-tight",children:"Config"})]}),t.jsx(ne,{})]})}export{fe as InstanceConfigPage};
|
|
2
|
+
|