@edspencer/paddock 0.66.1 → 0.66.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/server/dist/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 +27 -8
- package/packages/server/dist/routes/meta.js +32 -5
- package/packages/server/dist/routes/projects.js +71 -7
- package/packages/server/dist/ws-protocol.d.ts +52 -0
- package/packages/server/dist/ws-protocol.js +28 -13
- package/packages/server/dist/ws.js +89 -24
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DKzAHOhx.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-CRQP3Cfe.js → MarkdownRenderer-DDVO0zhl.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-Dd3h5V8h.js → ProjectRedirect-WczXTV0_.js} +1 -1
- package/packages/web/dist/assets/ProjectView-Dd_myWpV.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-BaUE8zaF.js → ProjectsGrid-BeJj0q6Y.js} +1 -1
- package/packages/web/dist/assets/{arc-DqMSW0As.js → arc-DXqVnx_r.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CNkQD1jU.js → architectureDiagram-3BPJPVTR-FgkfD1Bk.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BCMiOmt6.js → blockDiagram-GPEHLZMM-WwkIIJsI.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CU2soCji.js → c4Diagram-AAUBKEIU-BIzkTibx.js} +1 -1
- package/packages/web/dist/assets/channel-DUsOl5_p.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-nKhlJNTt.js → chunk-2J33WTMH-C4TBH3R6.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-YRTZj9zI.js → chunk-4BX2VUAB-DTKJZlRM.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-HO60VE45.js → chunk-55IACEB6-BC9_Qc8h.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-WZJH-boe.js → chunk-727SXJPM-D3gQ4rP3.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-PvDC7gYB.js → chunk-AQP2D5EJ-My2AXBvz.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BNRMTplH.js → chunk-FMBD7UC4-D09zE6Ow.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-2Q2ei8TR.js → chunk-ND2GUHAM-CUSgeCLl.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CG9VV7M6.js → chunk-QZHKN3VN-B6jVPYgy.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CIdDKEqH.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CIdDKEqH.js +2 -0
- package/packages/web/dist/assets/{core-DwSEw_oE.js → core-DPY0seI-.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BlMrBI7K.js → cose-bilkent-S5V4N54A-BasCiARv.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-tNoaleNU.js → dagre-BM42HDAG-Cimn11Qe.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-DRVCTpHL.js → diagram-2AECGRRQ-fe9cDHyn.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-BdS-96WV.js → diagram-5GNKFQAL-T0tQlfbl.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-Dv1yxz3s.js → diagram-KO2AKTUF-dLORrSiu.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BNXgifuS.js → diagram-LMA3HP47-iUMeaR-J.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-wlO0bxwP.js → diagram-OG6HWLK6-oEH6olHK.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-ChoppAEq.js → erDiagram-TEJ5UH35-VtQqF9SY.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-C__aYyei.js → flowDiagram-I6XJVG4X-wvF2EkJa.js} +1 -1
- package/packages/web/dist/assets/{format-u-YtEZFd.js → format-CvRqOKZU.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CjRe8foW.js → ganttDiagram-6RSMTGT7-ng4qJ0E1.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BGDQjiWq.js → gitGraphDiagram-PVQCEYII-BV4aWy8k.js} +1 -1
- package/packages/web/dist/assets/index-DGNSeqL-.js +71 -0
- package/packages/web/dist/assets/index-DMRbU1Jl.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BP-JaX-8.js → infoDiagram-5YYISTIA-BpGTxAjU.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-YnsAJBvC.js → ishikawaDiagram-YF4QCWOH-Cj36LyoS.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BM6JWTV9.js → journeyDiagram-JHISSGLW-0vNlg3um.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-gR2O0IaL.js → kanban-definition-UN3LZRKU-wijkEDaR.js} +1 -1
- package/packages/web/dist/assets/{linear-Dolq_eLR.js → linear-LqRymMD_.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-D1T_GQW7.js → mermaid.core-DPrhSs95.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-C3bzIyLZ.js → mindmap-definition-RKZ34NQL-en-uGqgC.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BrZ3ANGf.js → pieDiagram-4H26LBE5-DKBZ2Vnx.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BvLCHAEi.js → quadrantDiagram-W4KKPZXB--g-WtxyL.js} +1 -1
- package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CCzXAeaZ.js → requirementDiagram-4Y6WPE33-Dl4eYxDI.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-2Gf-ERTg.js → sankeyDiagram-5OEKKPKP-7lt4gqSw.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DMjDe28d.js → sequenceDiagram-3UESZ5HK-COgq-dXg.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CENtj5dw.js → stateDiagram-AJRCARHV-CL6XC79S.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BDlRyphY.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BW4gCpBB.js → timeline-definition-PNZ67QCA-C5egKeGT.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CiIuqcT9.js → vennDiagram-CIIHVFJN-C_UhtC8u.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-BQ1BPr5R.js → wardley-L42UT6IY-Bj9chUqz.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DBq9Ailv.js → wardleyDiagram-YWT4CUSO-D7VnZoRp.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D24VDECy.js → xychartDiagram-2RQKCTM6-VrWLDoi4.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-1BzMnJPo.js +0 -2
- package/packages/web/dist/assets/ProjectView-BRk6l152.js +0 -13
- 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/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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolveAttachmentsConfig, maxFileBytes, isTypeAllowed, } from "../attachments-config.js";
|
|
2
2
|
import { inferAttachmentKind } from "../attachments-hint.js";
|
|
3
|
-
import { buildInstanceConfig, writeInstanceConfig, validatePatch, instanceConfigPath, InstanceConfigError, } from "../instance-config.js";
|
|
3
|
+
import { buildInstanceConfig, writeInstanceConfig, validatePatch, instanceConfigPath, instanceConfigVersion, InstanceConfigError, } from "../instance-config.js";
|
|
4
4
|
import { TranscriptionError } from "../transcribe.js";
|
|
5
5
|
import { resolveModels, resolveDefaultModel } from "../models.js";
|
|
6
6
|
import { sendProjectError } from "../route-errors.js";
|
|
@@ -134,7 +134,7 @@ export function registerMetaRoutes(app, ctx) {
|
|
|
134
134
|
schema: {
|
|
135
135
|
tags: ["System"],
|
|
136
136
|
summary: "Read instance-wide config",
|
|
137
|
-
description: "Reports every surfaced instance-config field
|
|
137
|
+
description: "Reports every surfaced instance-config field for the top-level admin screen: `value` (effective in the running, boot-frozen process), `pendingValue` (what a restart would resolve, read from paddock.config.yaml on every request), `pendingRestart`, plus default / editable / sensitive / env-shadow flags. Top level carries `restartRequired` (any field diverges), `configPath`, and `configVersion` — a fingerprint of the file to echo back as `expectedVersion` on the PUT.",
|
|
138
138
|
response: {
|
|
139
139
|
200: {
|
|
140
140
|
description: "Instance config snapshot.",
|
|
@@ -148,13 +148,16 @@ export function registerMetaRoutes(app, ctx) {
|
|
|
148
148
|
schema: {
|
|
149
149
|
tags: ["System"],
|
|
150
150
|
summary: "Write instance-wide config",
|
|
151
|
-
description: "Writes the editable subset of the instance config to paddock.config.yaml (comment-preserving, atomic). Writes DO NOT hot-apply — the config is frozen at boot — so a successful write returns `{ restartRequired: true, configPath }` and the process keeps its current config until it restarts. Returns 400 on a malformed patch
|
|
151
|
+
description: "Writes the editable subset of the instance config to paddock.config.yaml (comment-preserving, atomic). Writes DO NOT hot-apply — the config is frozen at boot — so a successful write returns `{ restartRequired: true, configPath, configVersion }` and the process keeps its current config until it restarts. A `null` value clears that key (the built-in default applies again). Pass `expectedVersion` (the `configVersion` from the GET you were editing) to make the write conditional: 409 if the file changed underneath you, instead of silently overwriting another editor. Returns 400 on a malformed patch, an invalid/read-only/env-shadowed field, 500 on write failure.",
|
|
152
152
|
body: {
|
|
153
153
|
// Documentation-only (no validation/coercion): accept any/empty body.
|
|
154
154
|
type: ["object", "null"],
|
|
155
155
|
additionalProperties: true,
|
|
156
156
|
properties: {
|
|
157
157
|
patch: { description: "Map of config field name to new value to write." },
|
|
158
|
+
expectedVersion: {
|
|
159
|
+
description: "Optional. The `configVersion` this patch was composed against; the write is refused with 409 if the file no longer matches.",
|
|
160
|
+
},
|
|
158
161
|
},
|
|
159
162
|
},
|
|
160
163
|
response: {
|
|
@@ -163,6 +166,11 @@ export function registerMetaRoutes(app, ctx) {
|
|
|
163
166
|
type: "object",
|
|
164
167
|
additionalProperties: true,
|
|
165
168
|
},
|
|
169
|
+
409: {
|
|
170
|
+
description: "`expectedVersion` no longer matches the file — another writer got there first. Nothing was written.",
|
|
171
|
+
type: "object",
|
|
172
|
+
additionalProperties: true,
|
|
173
|
+
},
|
|
166
174
|
},
|
|
167
175
|
},
|
|
168
176
|
}, async (req, reply) => {
|
|
@@ -180,14 +188,33 @@ export function registerMetaRoutes(app, ctx) {
|
|
|
180
188
|
}
|
|
181
189
|
throw err;
|
|
182
190
|
}
|
|
191
|
+
const configPath = instanceConfigPath(cfg);
|
|
192
|
+
// Optimistic concurrency (#722). Opt-in — a client that sends no
|
|
193
|
+
// `expectedVersion` (curl, a script) writes unconditionally, as before —
|
|
194
|
+
// but the UI always sends one, so two tabs editing the same file now
|
|
195
|
+
// collide loudly instead of the second silently erasing the first.
|
|
196
|
+
if (Object.prototype.hasOwnProperty.call(req.body ?? {}, "expectedVersion")) {
|
|
197
|
+
const current = instanceConfigVersion(configPath);
|
|
198
|
+
if (current !== (req.body?.expectedVersion ?? null)) {
|
|
199
|
+
return reply.code(409).send({
|
|
200
|
+
error: "The config file changed on disk since this page loaded (another tab, or an editor). Nothing was written — reload the settings and apply your changes again.",
|
|
201
|
+
code: "config_conflict",
|
|
202
|
+
configVersion: current,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
183
206
|
try {
|
|
184
|
-
writeInstanceConfig(
|
|
207
|
+
writeInstanceConfig(configPath, pairs);
|
|
185
208
|
}
|
|
186
209
|
catch (err) {
|
|
187
210
|
return reply.code(500).send({ error: `failed to write config file: ${err.message}` });
|
|
188
211
|
}
|
|
189
212
|
// The write lands in the file but NOT in the running (frozen) config.
|
|
190
|
-
return {
|
|
213
|
+
return {
|
|
214
|
+
restartRequired: true,
|
|
215
|
+
configPath,
|
|
216
|
+
configVersion: instanceConfigVersion(configPath),
|
|
217
|
+
};
|
|
191
218
|
});
|
|
192
219
|
// Selectable models + the keeper/sweeper defaults (CONTRACT-v3 §3). The offered
|
|
193
220
|
// models are the instance ALLOW-LIST (issue #457 Step 2): the built-in catalog
|
|
@@ -13,7 +13,14 @@ import { quiesceProject, turnRunningError } from "../turn-interlock.js";
|
|
|
13
13
|
* (#553): the badge's semantics are "whatever this function returns", and there
|
|
14
14
|
* is no second implementation for the root to drift from. The completed-turn
|
|
15
15
|
* side comes from the cheap job-record scan (grouped by keeper agent), so this
|
|
16
|
-
* costs no `
|
|
16
|
+
* costs no transcript parse; `live` adds one cached session listing per
|
|
17
|
+
* workspace (see {@link liveSessionIds}).
|
|
18
|
+
*
|
|
19
|
+
* Two things are pruned OUT of the raw job-record fold, and the invariant they
|
|
20
|
+
* add up to is: **the badge counts exactly what the Home Unread feed counts.**
|
|
21
|
+
* A chat that no longer exists, and a chat the user archived. #488 made read
|
|
22
|
+
* state server-authoritative so surfaces could not diverge; a badge counting
|
|
23
|
+
* chats `/chats/attention` does not is that divergence one layer up.
|
|
17
24
|
*
|
|
18
25
|
* `key` is a WORKSPACE key, so `""` (the root) is a perfectly ordinary argument:
|
|
19
26
|
* `turnsByProject` is already keyed that way (`lastTurnCompletedAtByProject`
|
|
@@ -21,12 +28,27 @@ import { quiesceProject, turnRunningError } from "../turn-interlock.js";
|
|
|
21
28
|
* `keeperAgentName("")` encodes to `keeper-_root`. Nothing here may test `key`
|
|
22
29
|
* for truthiness.
|
|
23
30
|
*/
|
|
24
|
-
async function buildChatTurns(key, turnsByProject, user, readState, unread) {
|
|
31
|
+
async function buildChatTurns(key, turnsByProject, live, user, readState, unread, archive) {
|
|
25
32
|
const bySession = turnsByProject.get(key);
|
|
26
33
|
if (!bySession)
|
|
27
34
|
return [];
|
|
28
35
|
const keeper = keeperAgentName(key);
|
|
29
|
-
|
|
36
|
+
const rows = await Promise.all([...bySession].map(async ([sessionId, lastTurnCompletedAt]) => {
|
|
37
|
+
// #732, prune 1 — a chat that no longer exists. The job records this fold
|
|
38
|
+
// reads are removed with the chat now, but that cannot heal a badge
|
|
39
|
+
// already stuck, and a transcript can leave by routes that are not a
|
|
40
|
+
// delete at all (an adoption undone, a JSONL removed by hand). `live` is
|
|
41
|
+
// `null` when the listing FAILED, which is not the same as empty: an
|
|
42
|
+
// unreadable chat store must leave the badge alone, not silently zero it.
|
|
43
|
+
if (live && !live.has(sessionId))
|
|
44
|
+
return null;
|
|
45
|
+
// #732, prune 2 — archiving is the user saying "stop bothering me about
|
|
46
|
+
// this one", and `/chats/attention` has always honoured that. The badge
|
|
47
|
+
// did not, so the sidebar could read 3 while the Home Unread feed showed
|
|
48
|
+
// nothing. One rule, both surfaces: archived is silent. It is silenced,
|
|
49
|
+
// not consumed — unarchiving brings the chat back to both.
|
|
50
|
+
if (await archive.isArchived(keeper, sessionId).catch(() => false))
|
|
51
|
+
return null;
|
|
30
52
|
const lastSeen = await readState.getLastSeen(user, keeper, sessionId).catch(() => 0);
|
|
31
53
|
// Manual unread override (#458): carry it so the sidebar's workspace
|
|
32
54
|
// unread badge counts a manually-flagged chat, staying in step with
|
|
@@ -39,14 +61,42 @@ async function buildChatTurns(key, turnsByProject, user, readState, unread) {
|
|
|
39
61
|
...(isUnread ? { unread: true } : {}),
|
|
40
62
|
};
|
|
41
63
|
}));
|
|
64
|
+
return rows.filter((r) => r !== null);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The session ids a workspace actually still has, for {@link buildChatTurns}'s
|
|
68
|
+
* prune — or `null` if we could not find out.
|
|
69
|
+
*
|
|
70
|
+
* That distinction is the whole point: a failed listing must NOT be read as "no
|
|
71
|
+
* chats", or a transient discovery error would empty the badge instead of
|
|
72
|
+
* leaving it alone. `listSessions` is a superset of the keeper chats the fold
|
|
73
|
+
* covers (it also returns hook-agent chats), which is the safe direction — it
|
|
74
|
+
* can only fail to prune, never over-prune.
|
|
75
|
+
*
|
|
76
|
+
* This costs one session listing per workspace on `GET /api/projects`, which
|
|
77
|
+
* #529 was careful to keep off this route. It is affordable because it is not
|
|
78
|
+
* the expensive half: the listing is a cached directory read (core's discovery
|
|
79
|
+
* cache is mtime-keyed with a TTL), whereas the 61%-of-CPU problem #529 fixed
|
|
80
|
+
* was YAML-parsing every job record. `/chats/attention` already does exactly
|
|
81
|
+
* this listing across the whole fleet on the Home tab.
|
|
82
|
+
*/
|
|
83
|
+
async function liveSessionIds(herdctl, project) {
|
|
84
|
+
if (!project)
|
|
85
|
+
return null;
|
|
86
|
+
try {
|
|
87
|
+
return new Set((await herdctl.listSessions(project)).map((s) => s.sessionId));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
42
92
|
}
|
|
43
93
|
export function registerProjectRoutes(app, ctx) {
|
|
44
|
-
const { projects, herdctl, git, readState, unread, readStateUser } = ctx;
|
|
94
|
+
const { projects, herdctl, git, readState, unread, archive, readStateUser } = ctx;
|
|
45
95
|
app.get("/api/projects", {
|
|
46
96
|
schema: {
|
|
47
97
|
tags: ["Projects"],
|
|
48
98
|
summary: "List projects",
|
|
49
|
-
description: "Returns `{ projects, root }`. `projects` is an array of the root workspace's CHILDREN; each entry carries the project metadata plus a compact `chatTurns` array (per-chat `{ sessionId, lastTurnCompletedAt, lastSeen? }` for the sidebar UNREAD count) and a `dirty` uncommitted-file count. `root` is the ROOT workspace itself, carrying the same `chatTurns` field so the sidebar's Home entry gets the identical badge; it is `null` only if the root record could not be read.",
|
|
99
|
+
description: "Returns `{ projects, root }`. `projects` is an array of the root workspace's CHILDREN; each entry carries the project metadata plus a compact `chatTurns` array (per-chat `{ sessionId, lastTurnCompletedAt, lastSeen? }` for the sidebar UNREAD count) and a `dirty` uncommitted-file count. `chatTurns` covers only chats that still EXIST and are not ARCHIVED, so the badge cannot outlive its chats (#732) and agrees with `/chats/attention` on archived ones. `root` is the ROOT workspace itself, carrying the same `chatTurns` field so the sidebar's Home entry gets the identical badge; it is `null` only if the root record could not be read.",
|
|
50
100
|
response: {
|
|
51
101
|
200: {
|
|
52
102
|
description: "Object with a `projects` array (each item a project enriched with `chatTurns` and `dirty`) and a `root` workspace object enriched with `chatTurns`.",
|
|
@@ -79,7 +129,7 @@ export function registerProjectRoutes(app, ctx) {
|
|
|
79
129
|
const user = readStateUser(req);
|
|
80
130
|
const projectsOut = await Promise.all(list.map(async (p) => ({
|
|
81
131
|
...p,
|
|
82
|
-
chatTurns: await buildChatTurns(p.slug, turnsByProject, user, readState, unread),
|
|
132
|
+
chatTurns: await buildChatTurns(p.slug, turnsByProject, await liveSessionIds(herdctl, p), user, readState, unread, archive),
|
|
83
133
|
// The cheap store-wide sweep above buckets by path segment under
|
|
84
134
|
// `projectsRoot`, so it can only speak for projects whose working dir is
|
|
85
135
|
// the metadata dir. For one pointing somewhere else (a linked checkout,
|
|
@@ -94,7 +144,10 @@ export function registerProjectRoutes(app, ctx) {
|
|
|
94
144
|
// a file directly in the projects root has no bucket at all. Reporting 0
|
|
95
145
|
// would be a fabricated clean; the field is optional, so it stays absent.
|
|
96
146
|
const rootOut = root
|
|
97
|
-
? {
|
|
147
|
+
? {
|
|
148
|
+
...root,
|
|
149
|
+
chatTurns: await buildChatTurns(ROOT_KEY, turnsByProject, await liveSessionIds(herdctl, root), user, readState, unread, archive),
|
|
150
|
+
}
|
|
98
151
|
: null;
|
|
99
152
|
return { projects: projectsOut, root: rootOut };
|
|
100
153
|
});
|
|
@@ -437,6 +490,17 @@ export function registerProjectWorkspaceRoutes(app, ctx) {
|
|
|
437
490
|
catch (err) {
|
|
438
491
|
req.log.warn({ err }, "keeper-agent unregister failed (project dir already removed)");
|
|
439
492
|
}
|
|
493
|
+
// #734: and its herdctl JOB RECORDS, which `remove()` cannot reach — they
|
|
494
|
+
// live in the shared fleet-wide jobs dir, not under the project. They are
|
|
495
|
+
// keyed by agent name, which is derived from the slug, which is derived
|
|
496
|
+
// from the NAME: leaving them behind means a project created later with
|
|
497
|
+
// the same name inherits this one's run history — prompt text and reply
|
|
498
|
+
// summaries — plus a phantom unread badge over zero chats.
|
|
499
|
+
await herdctl
|
|
500
|
+
.purgeProjectJobs(project.slug, Object.keys(project.hooks ?? {}), Object.keys(project.triggers ?? {}))
|
|
501
|
+
.catch((err) => {
|
|
502
|
+
req.log.warn({ err }, "job-record purge failed (project is still deleted)");
|
|
503
|
+
});
|
|
440
504
|
return reply.code(200).send({ ok: true, slug: project.slug });
|
|
441
505
|
}
|
|
442
506
|
catch (err) {
|
|
@@ -233,6 +233,26 @@ export interface ChatSetQueueMessage {
|
|
|
233
233
|
* stored as the enqueue time — the server stamps that itself.
|
|
234
234
|
*/
|
|
235
235
|
ts?: number | null;
|
|
236
|
+
/**
|
|
237
|
+
* Files staged in the composer that must ride the queued message (#728).
|
|
238
|
+
*
|
|
239
|
+
* Attachments used to be consumed ONLY by `chat:send`, so a message queued
|
|
240
|
+
* during a live turn left them staged: the tray never cleared and the file
|
|
241
|
+
* silently rode whatever the user sent next — a message they never meant to
|
|
242
|
+
* attach it to. They belong to the queued message, so they travel with it into
|
|
243
|
+
* the shared slot and are sent by the drain.
|
|
244
|
+
*
|
|
245
|
+
* Merged into the slot as a UNION BY ID, never a replace: a write can only
|
|
246
|
+
* ever ADD to what is staged, so one client's `set_queue` cannot silently drop
|
|
247
|
+
* another's file, and a client re-asserting its queue on reload (which by then
|
|
248
|
+
* has an empty tray) is a no-op rather than a wipe. Attachments leave the slot
|
|
249
|
+
* only when it is cleared outright or popped back to a composer.
|
|
250
|
+
*/
|
|
251
|
+
attachments?: Array<{
|
|
252
|
+
id: string;
|
|
253
|
+
filename: string;
|
|
254
|
+
kind?: string;
|
|
255
|
+
}>;
|
|
236
256
|
};
|
|
237
257
|
}
|
|
238
258
|
/**
|
|
@@ -393,6 +413,16 @@ export interface ChatQueuedFlushedMessage {
|
|
|
393
413
|
* when the frame is just telling the client to clear a stale/already-sent copy.
|
|
394
414
|
*/
|
|
395
415
|
text?: string;
|
|
416
|
+
/**
|
|
417
|
+
* The attachments that rode the drained message (#728), so the client renders
|
|
418
|
+
* the auto-sent user bubble with its files exactly as a live send does. Only
|
|
419
|
+
* present alongside `text` (a clear-only frame carries neither).
|
|
420
|
+
*/
|
|
421
|
+
attachments?: Array<{
|
|
422
|
+
id: string;
|
|
423
|
+
filename: string;
|
|
424
|
+
kind?: string;
|
|
425
|
+
}>;
|
|
396
426
|
};
|
|
397
427
|
}
|
|
398
428
|
/**
|
|
@@ -414,6 +444,17 @@ export interface ChatQueuedStateMessage {
|
|
|
414
444
|
sessionId: string;
|
|
415
445
|
/** The chat's full queued text, or null when the slot is now empty. */
|
|
416
446
|
text: string | null;
|
|
447
|
+
/**
|
|
448
|
+
* The files staged on the slot (#728). Shared exactly like the text is: every
|
|
449
|
+
* attached client renders the same chips, so a file queued on one device is
|
|
450
|
+
* visible on the other rather than being invisible state that surfaces only
|
|
451
|
+
* when the drain sends it. Absent/empty when the slot holds no attachments.
|
|
452
|
+
*/
|
|
453
|
+
attachments?: Array<{
|
|
454
|
+
id: string;
|
|
455
|
+
filename: string;
|
|
456
|
+
kind?: string;
|
|
457
|
+
}>;
|
|
417
458
|
/**
|
|
418
459
|
* The slot's identity. A client adopts it as its own queue id, so its next
|
|
419
460
|
* edit updates this shared slot in place rather than appending beside it.
|
|
@@ -451,6 +492,17 @@ export interface ChatQueuedReturnedMessage {
|
|
|
451
492
|
sessionId: string;
|
|
452
493
|
/** The queued text, for the client to put back in its composer. */
|
|
453
494
|
text: string;
|
|
495
|
+
/**
|
|
496
|
+
* The files that were staged on the slot (#728), for the client to put back in
|
|
497
|
+
* its attachment tray. Stop returns the whole message, not just its prose —
|
|
498
|
+
* dropping the files here would be the same silent loss under a different name,
|
|
499
|
+
* and leaving them on the (now cleared) slot would strand them server-side.
|
|
500
|
+
*/
|
|
501
|
+
attachments?: Array<{
|
|
502
|
+
id: string;
|
|
503
|
+
filename: string;
|
|
504
|
+
kind?: string;
|
|
505
|
+
}>;
|
|
454
506
|
};
|
|
455
507
|
}
|
|
456
508
|
/**
|
|
@@ -166,6 +166,28 @@ export function resolveTurnUsage(state) {
|
|
|
166
166
|
outputTokens: Math.max(base.outputTokens, state.cumulative?.outputTokens ?? 0),
|
|
167
167
|
};
|
|
168
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Validate an optional wire list of composer attachment refs (#328/#728). Shared
|
|
171
|
+
* by `chat:send` and `chat:set_queue` — the queue carries the same refs the send
|
|
172
|
+
* does, so it validates them the same way rather than by a second, drifting copy.
|
|
173
|
+
* `undefined` is valid (the field is optional everywhere it appears).
|
|
174
|
+
*/
|
|
175
|
+
function isAttachmentRefList(v) {
|
|
176
|
+
if (v === undefined)
|
|
177
|
+
return true;
|
|
178
|
+
if (!Array.isArray(v))
|
|
179
|
+
return false;
|
|
180
|
+
for (const a of v) {
|
|
181
|
+
if (!a || typeof a !== "object")
|
|
182
|
+
return false;
|
|
183
|
+
const ao = a;
|
|
184
|
+
if (typeof ao.id !== "string" || typeof ao.filename !== "string")
|
|
185
|
+
return false;
|
|
186
|
+
if (ao.kind !== undefined && typeof ao.kind !== "string")
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
169
191
|
export function isClientMessage(data) {
|
|
170
192
|
if (typeof data !== "object" || data === null)
|
|
171
193
|
return false;
|
|
@@ -187,19 +209,8 @@ export function isClientMessage(data) {
|
|
|
187
209
|
return false;
|
|
188
210
|
if (p.model !== undefined && typeof p.model !== "string")
|
|
189
211
|
return false;
|
|
190
|
-
if (p.attachments
|
|
191
|
-
|
|
192
|
-
return false;
|
|
193
|
-
for (const a of p.attachments) {
|
|
194
|
-
if (!a || typeof a !== "object")
|
|
195
|
-
return false;
|
|
196
|
-
const ao = a;
|
|
197
|
-
if (typeof ao.id !== "string" || typeof ao.filename !== "string")
|
|
198
|
-
return false;
|
|
199
|
-
if (ao.kind !== undefined && typeof ao.kind !== "string")
|
|
200
|
-
return false;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
212
|
+
if (!isAttachmentRefList(p.attachments))
|
|
213
|
+
return false;
|
|
203
214
|
return p.sessionId === undefined || p.sessionId === null || typeof p.sessionId === "string";
|
|
204
215
|
}
|
|
205
216
|
if (m.type === "chat:command") {
|
|
@@ -247,6 +258,10 @@ export function isClientMessage(data) {
|
|
|
247
258
|
return false;
|
|
248
259
|
if (p.qid !== undefined && p.qid !== null && typeof p.qid !== "string")
|
|
249
260
|
return false;
|
|
261
|
+
// The queued message carries the composer's staged files (#728), validated
|
|
262
|
+
// exactly as `chat:send` validates its own.
|
|
263
|
+
if (!isAttachmentRefList(p.attachments))
|
|
264
|
+
return false;
|
|
250
265
|
return true;
|
|
251
266
|
}
|
|
252
267
|
return false;
|
|
@@ -57,7 +57,7 @@ import { keeperAgentName, keeperSlugFromAgent, } from "./herdctl.js";
|
|
|
57
57
|
import { consumeResumedTurn } from "./resume-drain.js";
|
|
58
58
|
import { isKnownModel, getContextLimit, DEFAULT_MODEL, isKnownDriveMode, } from "./models.js";
|
|
59
59
|
import { SessionHub } from "./session-hub.js";
|
|
60
|
-
import { partsOf } from "./queued-message.js";
|
|
60
|
+
import { hasQueuedContent, partsOf, } from "./queued-message.js";
|
|
61
61
|
import { resolveAttachmentsConfig } from "./attachments-config.js";
|
|
62
62
|
import { wrapAttachments, inferAttachmentKind } from "./attachments-hint.js";
|
|
63
63
|
import { sendFileServerDef, SEND_FILE_SERVER_KEY } from "./send-file-mcp.js";
|
|
@@ -476,15 +476,16 @@ export function makeChatHandler(deps) {
|
|
|
476
476
|
void deps.queuedMessage
|
|
477
477
|
.get(keeperAgentName(slug), sessionId)
|
|
478
478
|
.then((queued) => {
|
|
479
|
-
if (!queued
|
|
479
|
+
if (!hasQueuedContent(queued))
|
|
480
480
|
return;
|
|
481
481
|
send({
|
|
482
482
|
type: "chat:queued_state",
|
|
483
483
|
payload: {
|
|
484
484
|
projectSlug: slug,
|
|
485
485
|
sessionId,
|
|
486
|
-
text: queued.text,
|
|
486
|
+
text: queued.text || null,
|
|
487
487
|
...(queued.id ? { qid: queued.id } : {}),
|
|
488
|
+
...(queued.attachments?.length ? { attachments: queued.attachments } : {}),
|
|
488
489
|
},
|
|
489
490
|
});
|
|
490
491
|
})
|
|
@@ -499,6 +500,29 @@ export function makeChatHandler(deps) {
|
|
|
499
500
|
// `take` makes the read+clear atomic so two concurrent drains can never both
|
|
500
501
|
// send it; the flushed-parts ledger skips what was already drained (a stale
|
|
501
502
|
// client re-assert on reload) so it isn't sent twice.
|
|
503
|
+
/**
|
|
504
|
+
* Put attachments the drain could not carry back onto the (now empty) slot as
|
|
505
|
+
* an attachments-only queued message, and tell every client (#728). Only the
|
|
506
|
+
* slash-command path uses this: a command cannot carry files, and dropping
|
|
507
|
+
* them here would be the silent loss this whole change is about.
|
|
508
|
+
*/
|
|
509
|
+
const restageAttachments = async (slug, sessionId, attachments) => {
|
|
510
|
+
if (!deps.queuedMessage)
|
|
511
|
+
return;
|
|
512
|
+
const agent = keeperAgentName(slug);
|
|
513
|
+
await deps.queuedMessage
|
|
514
|
+
.set(agent, sessionId, {
|
|
515
|
+
text: "",
|
|
516
|
+
createdAtMs: Date.now(),
|
|
517
|
+
id: randomUUID(),
|
|
518
|
+
attachments,
|
|
519
|
+
})
|
|
520
|
+
.catch(() => undefined);
|
|
521
|
+
hub.broadcast(sessionId, {
|
|
522
|
+
type: "chat:queued_state",
|
|
523
|
+
payload: { projectSlug: slug, sessionId, text: null, attachments },
|
|
524
|
+
});
|
|
525
|
+
};
|
|
502
526
|
const drainQueue = async (slug, sessionId) => {
|
|
503
527
|
if (!deps.queuedMessage)
|
|
504
528
|
return;
|
|
@@ -509,7 +533,9 @@ export function makeChatHandler(deps) {
|
|
|
509
533
|
return;
|
|
510
534
|
const agent = keeperAgentName(slug);
|
|
511
535
|
const queued = await deps.queuedMessage.take(agent, sessionId).catch(() => null);
|
|
512
|
-
|
|
536
|
+
// A slot with no prose but staged files is a real message (#328/#728), so
|
|
537
|
+
// "is there anything here" is content, not text.
|
|
538
|
+
if (!hasQueuedContent(queued) || !queued)
|
|
513
539
|
return;
|
|
514
540
|
const markerKey = `${agent}${KEY_SEP}${sessionId}`;
|
|
515
541
|
// Parked by a Stop we couldn't hand back (see returnQueueToComposer): the
|
|
@@ -539,28 +565,44 @@ export function makeChatHandler(deps) {
|
|
|
539
565
|
const parts = partsOf(queued);
|
|
540
566
|
const stale = parts.some((p) => ledger.get(p.id)?.has(p.text));
|
|
541
567
|
const text = stale ? "" : queued.text;
|
|
568
|
+
// A leading-slash queued message is a slash command (e.g. "/compact"): the
|
|
569
|
+
// CLI dispatches it, and a command carries no attachments — exactly as the
|
|
570
|
+
// live composer refuses to attach files to one. Keep them on the slot rather
|
|
571
|
+
// than destroying them: they stay visible as a queued message of their own
|
|
572
|
+
// and go out after the command's turn, which is the only outcome here that
|
|
573
|
+
// isn't silent loss (#728). Unreachable from the current client, which never
|
|
574
|
+
// stages files onto a slash-command queue; reachable if one ever appends
|
|
575
|
+
// prose + a file behind an already-queued command.
|
|
576
|
+
const isCommand = !stale && text.startsWith("/");
|
|
577
|
+
const attachments = stale || isCommand ? [] : (queued.attachments ?? []);
|
|
578
|
+
// An attachments-only slot has no text but is still a real message
|
|
579
|
+
// (#328/#728), so what makes this a send is CONTENT, not prose.
|
|
580
|
+
const sending = !stale && (Boolean(text) || attachments.length > 0);
|
|
542
581
|
// Tell every attached client (origin + reconnected sockets) to clear its copy
|
|
543
|
-
// of this message. When we're really sending it, carry the text
|
|
544
|
-
// renders the sent bubble in-transcript
|
|
582
|
+
// of this message. When we're really sending it, carry the text + attachments
|
|
583
|
+
// so the client renders the sent bubble in-transcript exactly as a live send
|
|
584
|
+
// does; on a stale re-assert we only clear.
|
|
545
585
|
hub.broadcast(sessionId, {
|
|
546
586
|
type: "chat:queued_flushed",
|
|
547
587
|
payload: {
|
|
548
588
|
projectSlug: slug,
|
|
549
589
|
sessionId,
|
|
550
|
-
...(text ? { text } : {}),
|
|
590
|
+
...(sending && text ? { text } : {}),
|
|
591
|
+
...(sending && attachments.length > 0 ? { attachments } : {}),
|
|
551
592
|
},
|
|
552
593
|
});
|
|
553
|
-
if (!
|
|
594
|
+
if (!sending)
|
|
554
595
|
return;
|
|
555
596
|
// Every identity this slot carried is now flushed, so a re-assert from any
|
|
556
597
|
// client that held one of them is recognised rather than sent again.
|
|
557
598
|
for (const p of parts)
|
|
558
599
|
rememberFlushed(ledger, p.id, p.text);
|
|
559
600
|
// Broadcast the flush frame BEFORE kicking the turn so the user bubble renders
|
|
560
|
-
// above the reply. Run it detached, like a human send.
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
601
|
+
// above the reply. Run it detached, like a human send.
|
|
602
|
+
if (isCommand) {
|
|
603
|
+
if ((queued.attachments?.length ?? 0) > 0) {
|
|
604
|
+
void restageAttachments(slug, sessionId, queued.attachments);
|
|
605
|
+
}
|
|
564
606
|
void onChatCommand({
|
|
565
607
|
type: "chat:command",
|
|
566
608
|
payload: { projectSlug: slug, command: text, sessionId },
|
|
@@ -569,7 +611,14 @@ export function makeChatHandler(deps) {
|
|
|
569
611
|
else {
|
|
570
612
|
void onChatSend({
|
|
571
613
|
type: "chat:send",
|
|
572
|
-
payload: {
|
|
614
|
+
payload: {
|
|
615
|
+
projectSlug: slug,
|
|
616
|
+
sessionId,
|
|
617
|
+
message: text,
|
|
618
|
+
// The files the user staged behind this message (#728). They used to
|
|
619
|
+
// stay in the composer tray and ride whatever was sent NEXT.
|
|
620
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
621
|
+
},
|
|
573
622
|
});
|
|
574
623
|
}
|
|
575
624
|
};
|
|
@@ -582,6 +631,9 @@ export function makeChatHandler(deps) {
|
|
|
582
631
|
return;
|
|
583
632
|
const sessionId = msg.payload.sessionId ?? null;
|
|
584
633
|
const text = msg.payload.text ?? null;
|
|
634
|
+
// The composer's staged files ride the queued message (#728). They merge
|
|
635
|
+
// additively into the slot, so a write that carries none never removes any.
|
|
636
|
+
const attachments = msg.payload.attachments ?? [];
|
|
585
637
|
// Every chat belongs to a workspace keeper.
|
|
586
638
|
const agent = keeperAgentName(slug);
|
|
587
639
|
if (!sessionId) {
|
|
@@ -589,12 +641,15 @@ export function makeChatHandler(deps) {
|
|
|
589
641
|
// re-asserts it (same qid) once the id resolves, so it persists then.
|
|
590
642
|
return;
|
|
591
643
|
}
|
|
592
|
-
// Store or clear the queued message.
|
|
593
|
-
|
|
644
|
+
// Store or clear the queued message. A slot with staged files but no prose is
|
|
645
|
+
// a real message (#328: an attachment-only send is valid), so "is there
|
|
646
|
+
// anything to store" is text OR attachments — it used to be text alone, which
|
|
647
|
+
// is why an attachment-only submit during a live turn was a silent no-op.
|
|
648
|
+
if ((text && text.trim().length > 0) || attachments.length > 0) {
|
|
594
649
|
// A queued message is persisted verbatim and the whole sidecar is rewritten
|
|
595
650
|
// on every mutation, so an unbounded one is a cheap way to bloat the data
|
|
596
651
|
// dir. Refuse it loudly rather than silently truncating the user's text.
|
|
597
|
-
if (text.length > MAX_QUEUED_CHARS) {
|
|
652
|
+
if (text != null && text.length > MAX_QUEUED_CHARS) {
|
|
598
653
|
send({
|
|
599
654
|
type: "chat:error",
|
|
600
655
|
payload: {
|
|
@@ -611,7 +666,7 @@ export function makeChatHandler(deps) {
|
|
|
611
666
|
// first client's message with no signal to anyone — and then rendered it,
|
|
612
667
|
// in that client's own transcript, as a user bubble they never typed.
|
|
613
668
|
const next = await deps.queuedMessage
|
|
614
|
-
.upsert(agent, sessionId, { id: queueIdOf(msg.payload), text }, Date.now(), randomUUID)
|
|
669
|
+
.upsert(agent, sessionId, { id: queueIdOf(msg.payload), text: text ?? "", attachments }, Date.now(), randomUUID)
|
|
615
670
|
.catch(() => undefined);
|
|
616
671
|
// The queue is shared chat state, so announce it: every attached client
|
|
617
672
|
// renders the same slot, and echoing `qid` back on its next write is how a
|
|
@@ -623,8 +678,9 @@ export function makeChatHandler(deps) {
|
|
|
623
678
|
payload: {
|
|
624
679
|
projectSlug: slug,
|
|
625
680
|
sessionId,
|
|
626
|
-
text: next?.text
|
|
681
|
+
text: next?.text || null,
|
|
627
682
|
...(next?.id ? { qid: next.id } : {}),
|
|
683
|
+
...(next?.attachments?.length ? { attachments: next.attachments } : {}),
|
|
628
684
|
},
|
|
629
685
|
});
|
|
630
686
|
}
|
|
@@ -1247,8 +1303,9 @@ export function makeChatHandler(deps) {
|
|
|
1247
1303
|
return;
|
|
1248
1304
|
const agent = keeperAgentName(slug);
|
|
1249
1305
|
const queued = await deps.queuedMessage.take(agent, sessionId).catch(() => null);
|
|
1250
|
-
if (!queued
|
|
1306
|
+
if (!hasQueuedContent(queued) || !queued)
|
|
1251
1307
|
return;
|
|
1308
|
+
const attachments = queued.attachments ?? [];
|
|
1252
1309
|
const live = to && to.readyState === to.OPEN ? to : null;
|
|
1253
1310
|
if (!live) {
|
|
1254
1311
|
// Nobody to hand it to — the tab closed between the Stop and this turn
|
|
@@ -1268,18 +1325,26 @@ export function makeChatHandler(deps) {
|
|
|
1268
1325
|
payload: {
|
|
1269
1326
|
projectSlug: slug,
|
|
1270
1327
|
sessionId,
|
|
1271
|
-
text: queued.text,
|
|
1328
|
+
text: queued.text || null,
|
|
1272
1329
|
...(queued.id ? { qid: queued.id } : {}),
|
|
1330
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
1273
1331
|
},
|
|
1274
1332
|
});
|
|
1275
1333
|
return;
|
|
1276
1334
|
}
|
|
1277
|
-
// The stopping client gets the text
|
|
1278
|
-
//
|
|
1279
|
-
//
|
|
1335
|
+
// The stopping client gets the text AND the files staged behind it (#728) —
|
|
1336
|
+
// Stop returns the whole message, and a file left on the cleared slot would be
|
|
1337
|
+
// stranded server-side. Everyone else just sees the shared slot clear, WITH a
|
|
1338
|
+
// reason — a chip vanishing from under you with no explanation is the same
|
|
1339
|
+
// silence that made #629 bite.
|
|
1280
1340
|
writeFrame(live, {
|
|
1281
1341
|
type: "chat:queued_returned",
|
|
1282
|
-
payload: {
|
|
1342
|
+
payload: {
|
|
1343
|
+
projectSlug: slug,
|
|
1344
|
+
sessionId,
|
|
1345
|
+
text: queued.text,
|
|
1346
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
1347
|
+
},
|
|
1283
1348
|
});
|
|
1284
1349
|
hub.broadcast(sessionId, {
|
|
1285
1350
|
type: "chat:queued_state",
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{r as p,k as Y,b as w,j as t,A as B,l as Z,X as _,m as ee,n as te,p as ne,M as re,q as se}from"./index-DGNSeqL-.js";function ae(){const[e,n]=p.useState(null),[i,o]=p.useState(!0),[s,c]=p.useState(null),[l,h]=p.useState({}),[u,$]=p.useState(!1),[A,y]=p.useState(null),[D,S]=p.useState(!1),[f,q]=p.useState(""),[v,G]=p.useState(!1),[P,O]=p.useState(null),R=p.useRef(null),z=Y("(min-width: 1024px)");p.useEffect(()=>{let r=!0;return o(!0),w.getInstanceConfig().then(a=>{r&&(n(a),c(null))}).catch(a=>r&&c(a instanceof Error?a.message:String(a))).finally(()=>r&&o(!1)),()=>{r=!1}},[]);const H=(r,a)=>{S(!1),y(null),h(d=>({...d,[r]:a}))},K=r=>L(r,l),C=p.useMemo(()=>e?e.groups.flatMap(r=>r.fields):[],[e]),x=p.useMemo(()=>C.filter(r=>r.editable&&!r.envOverridden).filter(r=>Object.prototype.hasOwnProperty.call(l,r.key)).filter(r=>!M(l[r.key],T(r))).map(r=>r.key),[C,l]),I=p.useMemo(()=>new Set(x),[x]),m=p.useMemo(()=>{if(!e)return[];const r=f.trim().toLowerCase(),a=[];for(const d of e.groups){const g=r!==""&&d.label.toLowerCase().includes(r),j=d.fields.filter(b=>{const N=L(b,l);return v&&M(N,b.default)?!1:r===""||g?!0:fe(b).includes(r)});j.length>0&&a.push({...d,fields:j})}return a},[e,f,v,l]),V=m.reduce((r,a)=>r+a.fields.length,0),X=f.trim()!==""||v,J=m.some(r=>r.fields.some(a=>a.envOverridden));p.useEffect(()=>{const r=R.current;if(!r||m.length===0)return;const a=m.map(g=>g.id),d=()=>{const g=r.getBoundingClientRect().top;let j=a[0];for(const b of a){const N=document.getElementById(F(b));if(N)if(N.getBoundingClientRect().top-g<=80)j=b;else break}O(j)};return d(),r.addEventListener("scroll",d,{passive:!0}),()=>r.removeEventListener("scroll",d)},[m]);const Q=r=>{var d;const a=document.getElementById(F(r));(d=a==null?void 0:a.scrollIntoView)==null||d.call(a,{behavior:"smooth",block:"start"}),O(r)},U=async()=>{if(x.length===0||!e)return;const r={};for(const a of x)r[a]=l[a];$(!0),y(null);try{await w.updateInstanceConfig(r,e.configVersion);const a=await w.getInstanceConfig();n(a),h({}),S(!0)}catch(a){if(a instanceof te&&a.status===409)try{n(await w.getInstanceConfig())}catch{}y(a instanceof Error?a.message:String(a))}finally{$(!1)}},W=()=>{h({}),y(null),S(!1)};return t.jsxs(t.Fragment,{children:[t.jsxs("div",{className:"flex min-h-0 flex-1",children:[e&&t.jsx(ie,{groups:m,active:P,onJump:Q,dirtyCount:be(m,I)}),t.jsxs("div",{className:"flex min-h-0 min-w-0 flex-1 flex-col",children:[e&&t.jsx(oe,{query:f,onQuery:q,modifiedOnly:v,onModifiedOnly:G,visibleCount:V,totalCount:C.length,filtering:X,autoFocus:z}),t.jsx("div",{ref:R,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(le,{saved:D,configPath:e==null?void 0:e.configPath,restartRequired:(e==null?void 0:e.restartRequired)??!1,fileError:e==null?void 0:e.configFileError}),i&&t.jsx("p",{className:"text-sm text-paddock-500",children:"Loading…"}),s&&t.jsxs("p",{className:"text-sm text-red-600 dark:text-red-400",role:"alert",children:["Failed to load settings: ",s]}),e&&J&&t.jsx(de,{}),e&&V===0&&t.jsxs("p",{className:"py-8 text-center text-sm text-paddock-500",children:["No settings match"," ",f.trim()?t.jsxs(t.Fragment,{children:["“",t.jsx("span",{className:"font-medium",children:f.trim()}),"”"]}):"this filter","."]}),m.map((r,a)=>t.jsx(ce,{group:r,first:a===0,children:t.jsx("div",{className:"grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2",children:r.fields.map(d=>t.jsx(pe,{field:d,value:K(d),dirty:I.has(d.key),onChange:g=>H(d.key,g)},d.key))})},r.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:[A&&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"}),A]}),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:W,disabled:x.length===0||u,children:"Reset"}),t.jsx("button",{type:"button",className:"btn-primary",onClick:U,disabled:x.length===0||u,children:u?"Saving…":"Save changes"})]})]})}function ie({groups:e,active:n,onJump:i,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(s=>{const c=s.id===n,l=o[s.id]??0;return t.jsx("li",{children:t.jsxs("button",{type:"button",onClick:()=>i(s.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:s.label}),l>0&&t.jsx("span",{className:"h-1.5 w-1.5 shrink-0 rounded-full bg-accent","aria-label":`${l} unsaved`}),t.jsx("span",{className:"shrink-0 text-[11px] tabular-nums text-paddock-400",children:s.fields.length})]})},s.id)})})})}function oe({query:e,onQuery:n,modifiedOnly:i,onModifiedOnly:o,visibleCount:s,totalCount:c,filtering:l,autoFocus:h}){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(Z,{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:h,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(_,{width:13,height:13})})]}),t.jsx("button",{type:"button","aria-pressed":i,onClick:()=>o(!i),className:`shrink-0 rounded-md border px-2.5 py-1 text-[12px] transition-colors ${i?"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:l?`${s} of ${c}`:`${c} settings`})]})}function le({saved:e,configPath:n,restartRequired:i,fileError:o}){const s=o?"red":e?"emerald":"amber";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 ${s==="red"?"border-red-300 bg-red-50 text-red-800 dark:border-red-700/60 dark:bg-red-900/20 dark:text-red-300":s==="emerald"?"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:[s==="emerald"?t.jsx(ee,{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:o?t.jsxs(t.Fragment,{children:[t.jsxs("strong",{children:["Could not read ",E(n),"."]})," ",o," — the values below are the running instance's; what a restart would load is unknown until the file parses."]}):e?t.jsxs(t.Fragment,{children:[t.jsx("strong",{children:"Saved to disk."})," These changes take effect only after the server restarts."]}):i?t.jsxs(t.Fragment,{children:[t.jsx("strong",{children:"Restart pending."})," ",t.jsx("code",{className:"font-mono text-[12px]",children:E(n)})," holds changes the running instance has not picked up — the fields below marked"," ",t.jsx(k,{tone:"amber",children:"restart"})," differ from what is in force right now."]}):t.jsxs(t.Fragment,{children:["Changes here are written to ",t.jsx("code",{className:"font-mono text-[12px]",children:E(n)})," ","and take effect only after the server restarts — the running instance keeps its current config until then."]})})]})}function de(){return t.jsxs("p",{className:"mb-5 text-[12px] leading-snug text-paddock-500",children:["Settings marked ",t.jsx(k,{tone:"amber",children:"env"})," are overridden by an environment variable, which wins over this file — edit that variable (and restart) to change them."]})}function ce({group:e,first:n,children:i}){return t.jsxs("section",{id:F(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:i})]})}function pe({field:e,value:n,dirty:i,onChange:o}){const s=!e.editable||e.envOverridden,c=`cfg-${e.key}`,l=e.type==="boolean";return t.jsxs("div",{className:`min-w-0 ${ge(e,n)?"sm:col-span-2":""} ${i?"-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:s||l?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(k,{tone:"amber",children:"sensitive"}),e.envOverridden&&t.jsx(k,{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(k,{tone:"plain",children:"read-only"}),e.pendingRestart&&t.jsx(k,{tone:"amber",title:"The config file and the running instance disagree on this field — restart to apply.",children:"restart"})]}),l&&!s&&t.jsx(ue,{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}),s?t.jsx(he,{field:e,value:n}):!l&&t.jsx(me,{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}),e.pendingRestart&&t.jsxs("p",{className:"mt-1 text-[11px] leading-snug text-amber-600 dark:text-amber-500/90",children:["In force now: ",t.jsx("span",{className:"font-mono",children:xe(e.value)})]})]})}function xe(e){if(e==null||e==="")return"(not set)";const i=(Array.isArray(e)?e.join(", "):String(e)).replace(/\s+/g," ").trim();return i.length>60?`${i.slice(0,60)}…`:i}function k({children:e,tone:n,title:i}){return t.jsx("span",{title:i,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 ue({id:e,checked:n,onChange:i,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:s=>i(s.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 me({field:e,value:n,onChange:i,inputId:o}){if(e.type==="enum")return t.jsx("select",{id:o,className:"input mt-1.5",value:String(n??""),onChange:s=>i(s.target.value),children:(e.enumValues??[]).map(s=>t.jsx("option",{value:s,children:s},s))});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:s=>i(s.target.value===""?null:Number(s.target.value))});if(e.type==="text"){const s=n==null||n===e.default,c=n==null?String(e.default??""):String(n),l=typeof e.default=="string"&&!s;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:h=>i(h.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:s?"Using the built-in default.":n===""?"Empty — nothing will be appended.":`${c.length.toLocaleString()} characters.`}),l&&t.jsx("button",{type:"button",className:"shrink-0 underline underline-offset-2 hover:text-paddock-700 dark:hover:text-paddock-300",onClick:()=>i(null),children:"Restore default"})]})]})}if(e.type==="string-list"){const s=Array.isArray(n)?n.join(", "):String(n??"");return t.jsx("input",{id:o,type:"text",className:"input mt-1.5",value:s,onChange:c=>i(c.target.value.split(",").map(l=>l.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:s=>i(s.target.value)})}function he({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 ge(e,n){return e.type==="text"||e.type==="string-list"?!0:(Array.isArray(n)?n.join(", "):n==null?"":String(n)).length>38}function fe(e){return[e.label,e.key,e.help,e.envVar,...e.enumValues??[]].filter(Boolean).join(" ").toLowerCase()}function be(e,n){const i={};for(const o of e)i[o.id]=o.fields.filter(s=>n.has(s.key)).length;return i}const F=e=>`cfg-section-${e}`;function T(e){return e.pendingValue===void 0?e.value:e.pendingValue}function L(e,n){return Object.prototype.hasOwnProperty.call(n,e.key)?n[e.key]:T(e)}function M(e,n){return Array.isArray(e)&&Array.isArray(n)?e.length===n.length&&e.every((i,o)=>i===n[o]):e===n}function E(e){if(!e)return"paddock.config.yaml";const n=e.split("/");return n[n.length-1]||e}function je(){const e=ne(),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(re,{width:20,height:20})}),t.jsx(se,{width:18,height:18,className:"shrink-0 text-paddock-400"}),t.jsx("h1",{className:"text-[15px] font-semibold tracking-tight",children:"Config"})]}),t.jsx(ae,{})]})}export{je as InstanceConfigPage};
|
|
2
|
+
|