@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.
Files changed (88) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/herdctl-jobs.d.ts +20 -0
  3. package/packages/server/dist/herdctl-jobs.js +94 -0
  4. package/packages/server/dist/herdctl.d.ts +46 -0
  5. package/packages/server/dist/herdctl.js +58 -0
  6. package/packages/server/dist/instance-config.d.ts +56 -5
  7. package/packages/server/dist/instance-config.js +206 -24
  8. package/packages/server/dist/project-paths.d.ts +46 -1
  9. package/packages/server/dist/project-paths.js +66 -2
  10. package/packages/server/dist/project-types.d.ts +23 -0
  11. package/packages/server/dist/project-types.js +35 -0
  12. package/packages/server/dist/projects.d.ts +5 -2
  13. package/packages/server/dist/projects.js +71 -17
  14. package/packages/server/dist/queued-message.d.ts +32 -2
  15. package/packages/server/dist/queued-message.js +87 -8
  16. package/packages/server/dist/routes/chats.js +27 -8
  17. package/packages/server/dist/routes/meta.js +32 -5
  18. package/packages/server/dist/routes/projects.js +71 -7
  19. package/packages/server/dist/ws-protocol.d.ts +52 -0
  20. package/packages/server/dist/ws-protocol.js +28 -13
  21. package/packages/server/dist/ws.js +89 -24
  22. package/packages/server/package.json +1 -1
  23. package/packages/web/dist/assets/InstanceConfigPage-DKzAHOhx.js +2 -0
  24. package/packages/web/dist/assets/{MarkdownRenderer-CRQP3Cfe.js → MarkdownRenderer-DDVO0zhl.js} +1 -1
  25. package/packages/web/dist/assets/{ProjectRedirect-Dd3h5V8h.js → ProjectRedirect-WczXTV0_.js} +1 -1
  26. package/packages/web/dist/assets/ProjectView-Dd_myWpV.js +13 -0
  27. package/packages/web/dist/assets/{ProjectsGrid-BaUE8zaF.js → ProjectsGrid-BeJj0q6Y.js} +1 -1
  28. package/packages/web/dist/assets/{arc-DqMSW0As.js → arc-DXqVnx_r.js} +1 -1
  29. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CNkQD1jU.js → architectureDiagram-3BPJPVTR-FgkfD1Bk.js} +1 -1
  30. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BCMiOmt6.js → blockDiagram-GPEHLZMM-WwkIIJsI.js} +1 -1
  31. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CU2soCji.js → c4Diagram-AAUBKEIU-BIzkTibx.js} +1 -1
  32. package/packages/web/dist/assets/channel-DUsOl5_p.js +2 -0
  33. package/packages/web/dist/assets/{chunk-2J33WTMH-nKhlJNTt.js → chunk-2J33WTMH-C4TBH3R6.js} +1 -1
  34. package/packages/web/dist/assets/{chunk-4BX2VUAB-YRTZj9zI.js → chunk-4BX2VUAB-DTKJZlRM.js} +1 -1
  35. package/packages/web/dist/assets/{chunk-55IACEB6-HO60VE45.js → chunk-55IACEB6-BC9_Qc8h.js} +1 -1
  36. package/packages/web/dist/assets/{chunk-727SXJPM-WZJH-boe.js → chunk-727SXJPM-D3gQ4rP3.js} +1 -1
  37. package/packages/web/dist/assets/{chunk-AQP2D5EJ-PvDC7gYB.js → chunk-AQP2D5EJ-My2AXBvz.js} +1 -1
  38. package/packages/web/dist/assets/{chunk-FMBD7UC4-BNRMTplH.js → chunk-FMBD7UC4-D09zE6Ow.js} +1 -1
  39. package/packages/web/dist/assets/{chunk-ND2GUHAM-2Q2ei8TR.js → chunk-ND2GUHAM-CUSgeCLl.js} +1 -1
  40. package/packages/web/dist/assets/{chunk-QZHKN3VN-CG9VV7M6.js → chunk-QZHKN3VN-B6jVPYgy.js} +1 -1
  41. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CIdDKEqH.js +2 -0
  42. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CIdDKEqH.js +2 -0
  43. package/packages/web/dist/assets/{core-DwSEw_oE.js → core-DPY0seI-.js} +1 -1
  44. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BlMrBI7K.js → cose-bilkent-S5V4N54A-BasCiARv.js} +1 -1
  45. package/packages/web/dist/assets/{dagre-BM42HDAG-tNoaleNU.js → dagre-BM42HDAG-Cimn11Qe.js} +1 -1
  46. package/packages/web/dist/assets/{diagram-2AECGRRQ-DRVCTpHL.js → diagram-2AECGRRQ-fe9cDHyn.js} +1 -1
  47. package/packages/web/dist/assets/{diagram-5GNKFQAL-BdS-96WV.js → diagram-5GNKFQAL-T0tQlfbl.js} +1 -1
  48. package/packages/web/dist/assets/{diagram-KO2AKTUF-Dv1yxz3s.js → diagram-KO2AKTUF-dLORrSiu.js} +1 -1
  49. package/packages/web/dist/assets/{diagram-LMA3HP47-BNXgifuS.js → diagram-LMA3HP47-iUMeaR-J.js} +1 -1
  50. package/packages/web/dist/assets/{diagram-OG6HWLK6-wlO0bxwP.js → diagram-OG6HWLK6-oEH6olHK.js} +1 -1
  51. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-ChoppAEq.js → erDiagram-TEJ5UH35-VtQqF9SY.js} +1 -1
  52. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-C__aYyei.js → flowDiagram-I6XJVG4X-wvF2EkJa.js} +1 -1
  53. package/packages/web/dist/assets/{format-u-YtEZFd.js → format-CvRqOKZU.js} +1 -1
  54. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CjRe8foW.js → ganttDiagram-6RSMTGT7-ng4qJ0E1.js} +1 -1
  55. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BGDQjiWq.js → gitGraphDiagram-PVQCEYII-BV4aWy8k.js} +1 -1
  56. package/packages/web/dist/assets/index-DGNSeqL-.js +71 -0
  57. package/packages/web/dist/assets/index-DMRbU1Jl.css +1 -0
  58. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BP-JaX-8.js → infoDiagram-5YYISTIA-BpGTxAjU.js} +1 -1
  59. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-YnsAJBvC.js → ishikawaDiagram-YF4QCWOH-Cj36LyoS.js} +1 -1
  60. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BM6JWTV9.js → journeyDiagram-JHISSGLW-0vNlg3um.js} +1 -1
  61. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-gR2O0IaL.js → kanban-definition-UN3LZRKU-wijkEDaR.js} +1 -1
  62. package/packages/web/dist/assets/{linear-Dolq_eLR.js → linear-LqRymMD_.js} +1 -1
  63. package/packages/web/dist/assets/{mermaid.core-D1T_GQW7.js → mermaid.core-DPrhSs95.js} +4 -4
  64. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-C3bzIyLZ.js → mindmap-definition-RKZ34NQL-en-uGqgC.js} +1 -1
  65. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BrZ3ANGf.js → pieDiagram-4H26LBE5-DKBZ2Vnx.js} +1 -1
  66. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BvLCHAEi.js → quadrantDiagram-W4KKPZXB--g-WtxyL.js} +1 -1
  67. package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
  68. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CCzXAeaZ.js → requirementDiagram-4Y6WPE33-Dl4eYxDI.js} +1 -1
  69. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-2Gf-ERTg.js → sankeyDiagram-5OEKKPKP-7lt4gqSw.js} +1 -1
  70. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DMjDe28d.js → sequenceDiagram-3UESZ5HK-COgq-dXg.js} +1 -1
  71. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CENtj5dw.js → stateDiagram-AJRCARHV-CL6XC79S.js} +1 -1
  72. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BDlRyphY.js +2 -0
  73. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BW4gCpBB.js → timeline-definition-PNZ67QCA-C5egKeGT.js} +1 -1
  74. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CiIuqcT9.js → vennDiagram-CIIHVFJN-C_UhtC8u.js} +1 -1
  75. package/packages/web/dist/assets/{wardley-L42UT6IY-BQ1BPr5R.js → wardley-L42UT6IY-Bj9chUqz.js} +1 -1
  76. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DBq9Ailv.js → wardleyDiagram-YWT4CUSO-D7VnZoRp.js} +1 -1
  77. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D24VDECy.js → xychartDiagram-2RQKCTM6-VrWLDoi4.js} +1 -1
  78. package/packages/web/dist/index.html +2 -2
  79. package/packages/web/dist/sw.js +1 -1
  80. package/packages/web/dist/assets/InstanceConfigPage-1BzMnJPo.js +0 -2
  81. package/packages/web/dist/assets/ProjectView-BRk6l152.js +0 -13
  82. package/packages/web/dist/assets/channel-Ynyq4GPh.js +0 -2
  83. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-1Pp25wwy.js +0 -2
  84. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-1Pp25wwy.js +0 -2
  85. package/packages/web/dist/assets/index-B3K5nsLe.css +0 -1
  86. package/packages/web/dist/assets/index-Cc5bQxZk.js +0 -71
  87. package/packages/web/dist/assets/queued-DGmoZW59.js +0 -2
  88. 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 (value / default / editable / sensitive / env-shadow) for the top-level admin screen. Returns a JSON object keyed by 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 or invalid field, 500 on write failure.",
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(instanceConfigPath(cfg), pairs);
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 { restartRequired: true, configPath: instanceConfigPath(cfg) };
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 `listSessions` fan-out and no transcript parse.
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
- return Promise.all([...bySession].map(async ([sessionId, lastTurnCompletedAt]) => {
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
- ? { ...root, chatTurns: await buildChatTurns(ROOT_KEY, turnsByProject, user, readState, unread) }
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 !== undefined) {
191
- if (!Array.isArray(p.attachments))
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?.text)
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
- if (!queued?.text)
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 so the client
544
- // renders the sent bubble in-transcript; on a stale re-assert we only clear.
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 (!text)
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. A leading-slash queued
561
- // message is a slash command (e.g. "/compact"): route it through the command
562
- // path so the CLI dispatches it — matching how the composer sends one live.
563
- if (text.startsWith("/")) {
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: { projectSlug: slug, sessionId, message: text },
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
- if (text && text.trim().length > 0) {
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 ?? null,
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?.text)
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; everyone else just sees the shared slot
1278
- // clear, WITH a reason — a chip vanishing from under you with no explanation
1279
- // is the same silence that made #629 bite.
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: { projectSlug: slug, sessionId, text: queued.text },
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paddock/server",
3
- "version": "0.66.1",
3
+ "version": "0.66.2",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -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
+