@edspencer/paddock 0.66.1 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/server/dist/herdctl-jobs.d.ts +20 -0
- package/packages/server/dist/herdctl-jobs.js +94 -0
- package/packages/server/dist/herdctl.d.ts +46 -0
- package/packages/server/dist/herdctl.js +58 -0
- package/packages/server/dist/instance-config.d.ts +56 -5
- package/packages/server/dist/instance-config.js +206 -24
- package/packages/server/dist/project-paths.d.ts +46 -1
- package/packages/server/dist/project-paths.js +66 -2
- package/packages/server/dist/project-types.d.ts +23 -0
- package/packages/server/dist/project-types.js +35 -0
- package/packages/server/dist/projects.d.ts +5 -2
- package/packages/server/dist/projects.js +71 -17
- package/packages/server/dist/queued-message.d.ts +32 -2
- package/packages/server/dist/queued-message.js +87 -8
- package/packages/server/dist/routes/chats.js +41 -10
- package/packages/server/dist/routes/meta.js +32 -5
- package/packages/server/dist/routes/projects.js +71 -7
- package/packages/server/dist/session-hub.d.ts +10 -0
- package/packages/server/dist/session-hub.js +16 -2
- package/packages/server/dist/ws-protocol.d.ts +67 -0
- package/packages/server/dist/ws-protocol.js +28 -13
- package/packages/server/dist/ws.js +90 -24
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-sDbCoFxC.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-CRQP3Cfe.js → MarkdownRenderer-MaSe5aAI.js} +11 -11
- package/packages/web/dist/assets/ProjectMenu-DmWpr9od.js +2 -0
- package/packages/web/dist/assets/ProjectRedirect-BJeDERUd.js +2 -0
- package/packages/web/dist/assets/ProjectView-Bbecs1u7.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-BE4cAtu5.js +2 -0
- package/packages/web/dist/assets/Surfaces-DtSsbnLh.js +2 -0
- package/packages/web/dist/assets/{arc-DqMSW0As.js → arc-NLb6J_GZ.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CNkQD1jU.js → architectureDiagram-3BPJPVTR-BbiSIctn.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BCMiOmt6.js → blockDiagram-GPEHLZMM-Br1sRzRE.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CU2soCji.js → c4Diagram-AAUBKEIU-DUvJurko.js} +1 -1
- package/packages/web/dist/assets/channel-bezC0w5b.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-nKhlJNTt.js → chunk-2J33WTMH-Cm_E_LAD.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-YRTZj9zI.js → chunk-4BX2VUAB-CK6R2MCN.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-HO60VE45.js → chunk-55IACEB6-D7HXvxGy.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-WZJH-boe.js → chunk-727SXJPM-BLwevzlY.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-PvDC7gYB.js → chunk-AQP2D5EJ-CpLWD5so.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BNRMTplH.js → chunk-FMBD7UC4-BNlt1t19.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-2Q2ei8TR.js → chunk-ND2GUHAM-CIt5gAq8.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CG9VV7M6.js → chunk-QZHKN3VN-B1uwIQ9c.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DMgrTmQU.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DMgrTmQU.js +2 -0
- package/packages/web/dist/assets/{core-DwSEw_oE.js → core-HXUN76Po.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BlMrBI7K.js → cose-bilkent-S5V4N54A-DJ4_5jxB.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-tNoaleNU.js → dagre-BM42HDAG-Bc9-haAu.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-DRVCTpHL.js → diagram-2AECGRRQ-nLOtPDzu.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-BdS-96WV.js → diagram-5GNKFQAL-DThV84R3.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-Dv1yxz3s.js → diagram-KO2AKTUF-C5hcR1vA.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BNXgifuS.js → diagram-LMA3HP47-BEFzrSHy.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-wlO0bxwP.js → diagram-OG6HWLK6-BY5lPsX7.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-ChoppAEq.js → erDiagram-TEJ5UH35-y_kB8ETg.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-C__aYyei.js → flowDiagram-I6XJVG4X-Cuhha5cF.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CjRe8foW.js → ganttDiagram-6RSMTGT7-DFJpciK5.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BGDQjiWq.js → gitGraphDiagram-PVQCEYII-FoA0yuh-.js} +1 -1
- package/packages/web/dist/assets/index-BxLurJvz.css +1 -0
- package/packages/web/dist/assets/index-C_PXsGTD.js +71 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BP-JaX-8.js → infoDiagram-5YYISTIA-CKYqD8-c.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-YnsAJBvC.js → ishikawaDiagram-YF4QCWOH-CCIxHIBw.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BM6JWTV9.js → journeyDiagram-JHISSGLW-lGYoSyk0.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-gR2O0IaL.js → kanban-definition-UN3LZRKU-B3D7CIwy.js} +1 -1
- package/packages/web/dist/assets/{linear-Dolq_eLR.js → linear-BiplJBex.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-D1T_GQW7.js → mermaid.core-BJ7aEinZ.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-C3bzIyLZ.js → mindmap-definition-RKZ34NQL-BiXcOtHo.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BrZ3ANGf.js → pieDiagram-4H26LBE5-tnkj45YF.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BvLCHAEi.js → quadrantDiagram-W4KKPZXB-LqReq3Gl.js} +1 -1
- package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CCzXAeaZ.js → requirementDiagram-4Y6WPE33-C1uk9Jty.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-2Gf-ERTg.js → sankeyDiagram-5OEKKPKP-eQQ3OKrV.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DMjDe28d.js → sequenceDiagram-3UESZ5HK-C6-a0lH4.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CENtj5dw.js → stateDiagram-AJRCARHV-CsTOkKqz.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Bsc-VlVI.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BW4gCpBB.js → timeline-definition-PNZ67QCA-DUBXH1x-.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CiIuqcT9.js → vennDiagram-CIIHVFJN-CcQrNnou.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-BQ1BPr5R.js → wardley-L42UT6IY-Br823uUV.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DBq9Ailv.js → wardleyDiagram-YWT4CUSO-DRBECQc_.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D24VDECy.js → xychartDiagram-2RQKCTM6--HtSFP24.js} +1 -1
- package/packages/web/dist/fonts/alegreya-latin-italic.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-latin.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-sc-latin-500.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-sc-latin-700.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-400.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-700.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-italic.woff2 +0 -0
- package/packages/web/dist/fonts/iosevka-terminal-400.woff2 +0 -0
- package/packages/web/dist/fonts/iosevka-terminal-700.woff2 +0 -0
- package/packages/web/dist/fonts/martian-mono-latin.woff2 +0 -0
- package/packages/web/dist/fonts/saira-latin.woff2 +0 -0
- package/packages/web/dist/index.html +61 -12
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-1BzMnJPo.js +0 -2
- package/packages/web/dist/assets/ProjectRedirect-Dd3h5V8h.js +0 -2
- package/packages/web/dist/assets/ProjectView-BRk6l152.js +0 -13
- package/packages/web/dist/assets/ProjectsGrid-BaUE8zaF.js +0 -2
- package/packages/web/dist/assets/channel-Ynyq4GPh.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-1Pp25wwy.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-1Pp25wwy.js +0 -2
- package/packages/web/dist/assets/format-u-YtEZFd.js +0 -2
- package/packages/web/dist/assets/index-B3K5nsLe.css +0 -1
- package/packages/web/dist/assets/index-Cc5bQxZk.js +0 -71
- package/packages/web/dist/assets/queued-DGmoZW59.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-F7o6JfFe.js +0 -2
|
@@ -134,20 +134,23 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
134
134
|
*
|
|
135
135
|
* `running` is authoritative (the live hub), not inferred from timestamps.
|
|
136
136
|
* `unread` reuses the sidebar badge's derivation — the manual override (#458)
|
|
137
|
-
* or a completed turn newer than the read watermark (#189) —
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* `ArchiveStore
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
137
|
+
* or a completed turn newer than the read watermark (#189) — INCLUDING the
|
|
138
|
+
* archived exclusion below.
|
|
139
|
+
*
|
|
140
|
+
* That last part used to be this route's alone: the badge never consulted
|
|
141
|
+
* `ArchiveStore`, so an archived unread chat counted toward the sidebar while
|
|
142
|
+
* being absent from this feed, and the two surfaces disagreed by exactly that
|
|
143
|
+
* set. #732 settled it the way this route already read it — archiving is the
|
|
144
|
+
* user filing a chat away, so it silences both — and `buildChatTurns` now
|
|
145
|
+
* applies the same rule. Keep the two in step: read state was made
|
|
146
|
+
* server-authoritative (#488) so surfaces could not diverge, and a rule that
|
|
147
|
+
* lives in only one of them is that divergence again.
|
|
145
148
|
*/
|
|
146
149
|
app.get("/chats/attention", {
|
|
147
150
|
schema: {
|
|
148
151
|
tags: ["Chats"],
|
|
149
152
|
summary: "List the chats in this workspace's subtree that are running or unread",
|
|
150
|
-
description: "Returns `{ running, unread }` — the chats in this workspace AND its descendants that currently have a live turn (`running`) or hold an unread reply (`unread`). Each row is a chat DTO plus the `projectSlug`/`projectName` it belongs to, so a fleet-wide list stays attributable. On the root mount (`/api/root/chats/attention`) the subtree is the whole instance, because the root workspace's key (`\"\"`) prefixes every workspace key; on a project mount it is that project alone. A chat is never in both lists: a live turn hasn't landed a reply yet, so running wins.",
|
|
153
|
+
description: "Returns `{ running, unread }` — the chats in this workspace AND its descendants that currently have a live turn (`running`) or hold an unread reply (`unread`). Each row is a chat DTO plus the `projectSlug`/`projectName` it belongs to, so a fleet-wide list stays attributable. On the root mount (`/api/root/chats/attention`) the subtree is the whole instance, because the root workspace's key (`\"\"`) prefixes every workspace key; on a project mount it is that project alone. A chat is never in both lists: a live turn hasn't landed a reply yet, so running wins. `running` rows additionally carry resolved `contextTokens`/`contextLimit` so a caller can draw a context gauge for live work; `unread` rows do not, because resolving usage streams a transcript per chat and only the running set is bounded by how many turns can be in flight at once.",
|
|
151
154
|
params: {
|
|
152
155
|
type: "object",
|
|
153
156
|
properties: {
|
|
@@ -194,7 +197,19 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
194
197
|
const provenanceOf = (s) => runProvenance.get(s.sessionId);
|
|
195
198
|
const triggerOf = makeTriggerResolver(p);
|
|
196
199
|
const parentOf = makeParentResolver(runProvenance, messageProvenance, p.slug, (id) => parentDetach.isDetached(keeper, id));
|
|
197
|
-
|
|
200
|
+
// Usage is deliberately NOT resolved for the whole subtree: it streams
|
|
201
|
+
// a transcript per chat, and this route refetches on every turn
|
|
202
|
+
// boundary across the fleet. A RUNNING chat is the one exception —
|
|
203
|
+
// every context gauge in the app (Home's running rows, the fleet
|
|
204
|
+
// readout) reads `contextTokens`/`contextLimit` off these rows, and
|
|
205
|
+
// with no resolver they were always null, so the gauges rendered
|
|
206
|
+
// blank. The running set is bounded by how many turns can be in
|
|
207
|
+
// flight at once (a handful), not by how many chats the instance
|
|
208
|
+
// holds, so the cost stays proportional to live work and not to
|
|
209
|
+
// history.
|
|
210
|
+
const usageOf = chatUsageResolver(p.dir, p.model ?? DEFAULT_MODEL);
|
|
211
|
+
const usageIfRunning = async (s) => isRunning(s.sessionId) ? usageOf(s) : null;
|
|
212
|
+
const chats = await buildProjectChats(p.dir, sessions, usageIfRunning, archivedOf, lastTurnAt, lastSeenOf, provenanceOf, triggerOf, starredOf, unreadOf, parentOf);
|
|
198
213
|
for (const c of chats) {
|
|
199
214
|
const row = { ...c, projectSlug: p.slug, projectName: p.name };
|
|
200
215
|
if (isRunning(c.sessionId)) {
|
|
@@ -632,6 +647,13 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
632
647
|
}
|
|
633
648
|
await cleanupAttachments(agent, req.params.sessionId);
|
|
634
649
|
const { removed, retained } = await herdctl.deleteSession(agent, req.params.sessionId);
|
|
650
|
+
// #732: and the chat's herdctl job records, which are what feeds the
|
|
651
|
+
// sidebar unread badge. Left behind, they kept the badge counting a chat
|
|
652
|
+
// there was nothing left to open to clear. Only when the transcript was
|
|
653
|
+
// actually REMOVED — see `purgeSessionJobs` for why a `retained`
|
|
654
|
+
// (released) chat keeps its records.
|
|
655
|
+
if (removed)
|
|
656
|
+
await herdctl.purgeSessionJobs([req.params.sessionId]);
|
|
635
657
|
// Drop any archived/starred/unread/detached flag so a future session id
|
|
636
658
|
// can't inherit it. (Read-state watermarks, other users' unread overrides,
|
|
637
659
|
// provenance and any queued message are deliberately NOT swept here — that
|
|
@@ -1248,6 +1270,10 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1248
1270
|
const user = readStateUser(req);
|
|
1249
1271
|
const removed = [];
|
|
1250
1272
|
const failed = [];
|
|
1273
|
+
// The subset whose transcript was genuinely deleted — the ids whose job
|
|
1274
|
+
// records may be purged (#732). Narrower than `removed`, which also
|
|
1275
|
+
// holds RELEASED chats: those are still listed, so their records stay.
|
|
1276
|
+
const deleted = [];
|
|
1251
1277
|
for (const sessionId of sessionIds) {
|
|
1252
1278
|
try {
|
|
1253
1279
|
// #731: same interlock as the single delete, per id. A chat whose
|
|
@@ -1270,11 +1296,16 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1270
1296
|
removed.push(sessionId);
|
|
1271
1297
|
else
|
|
1272
1298
|
failed.push(sessionId);
|
|
1299
|
+
if (gone.removed)
|
|
1300
|
+
deleted.push(sessionId);
|
|
1273
1301
|
}
|
|
1274
1302
|
catch {
|
|
1275
1303
|
failed.push(sessionId);
|
|
1276
1304
|
}
|
|
1277
1305
|
}
|
|
1306
|
+
// ONE jobs-dir pass for the whole batch (#732) — purging per chat would
|
|
1307
|
+
// re-scan the shared directory once per id, up to 500 times.
|
|
1308
|
+
await herdctl.purgeSessionJobs(deleted);
|
|
1278
1309
|
// Drop the archived/starred/unread/detached flags of the chats that were
|
|
1279
1310
|
// ACTUALLY removed, so a recycled session id can't inherit stale state.
|
|
1280
1311
|
//
|
|
@@ -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) {
|
|
@@ -72,6 +72,14 @@ interface Turn {
|
|
|
72
72
|
baseSeq: number;
|
|
73
73
|
nextSeq: number;
|
|
74
74
|
running: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Epoch-ms the turn began. The hub is the only place that knows this: a job
|
|
77
|
+
* record is written when a turn ENDS, and the transcript's own timestamps are
|
|
78
|
+
* the model's, not the run's. Surfaced on {@link ActiveInfo} so a client that
|
|
79
|
+
* loads mid-turn — or reconnects — can still say how long the fleet has been
|
|
80
|
+
* working, rather than restarting every clock at zero on every page load.
|
|
81
|
+
*/
|
|
82
|
+
startedAt: number;
|
|
75
83
|
evictTimer: ReturnType<typeof setTimeout> | null;
|
|
76
84
|
}
|
|
77
85
|
/** How long a completed turn's buffer lingers so an end-of-turn reconnect still gets the tail. */
|
|
@@ -97,6 +105,8 @@ export interface ActiveInfo {
|
|
|
97
105
|
/** The running turn's cancellable job id, if known yet (null early in a turn). */
|
|
98
106
|
jobId: string | null;
|
|
99
107
|
running: boolean;
|
|
108
|
+
/** Epoch-ms this turn started. See {@link Turn.startedAt}. */
|
|
109
|
+
startedAt: number;
|
|
100
110
|
}
|
|
101
111
|
/** Why a turn is being cancelled, and which socket asked for it. */
|
|
102
112
|
export interface CancelIntent {
|
|
@@ -107,6 +107,7 @@ export class SessionHub {
|
|
|
107
107
|
baseSeq: 0,
|
|
108
108
|
nextSeq: 0,
|
|
109
109
|
running: true,
|
|
110
|
+
startedAt: Date.now(),
|
|
110
111
|
evictTimer: null,
|
|
111
112
|
};
|
|
112
113
|
const handle = new TurnHandle(this, turn);
|
|
@@ -203,14 +204,26 @@ export class SessionHub {
|
|
|
203
204
|
const turn = this.bySession.get(sessionId);
|
|
204
205
|
if (!turn || !turn.running)
|
|
205
206
|
return null;
|
|
206
|
-
return {
|
|
207
|
+
return {
|
|
208
|
+
sessionId,
|
|
209
|
+
projectSlug: turn.projectSlug,
|
|
210
|
+
jobId: turn.jobId,
|
|
211
|
+
running: true,
|
|
212
|
+
startedAt: turn.startedAt,
|
|
213
|
+
};
|
|
207
214
|
}
|
|
208
215
|
/** Snapshot of every session with a running turn (for a newly-connected client). */
|
|
209
216
|
runningSessions() {
|
|
210
217
|
const out = [];
|
|
211
218
|
for (const [sessionId, turn] of this.bySession) {
|
|
212
219
|
if (turn.running) {
|
|
213
|
-
out.push({
|
|
220
|
+
out.push({
|
|
221
|
+
sessionId,
|
|
222
|
+
projectSlug: turn.projectSlug,
|
|
223
|
+
jobId: turn.jobId,
|
|
224
|
+
running: true,
|
|
225
|
+
startedAt: turn.startedAt,
|
|
226
|
+
});
|
|
214
227
|
}
|
|
215
228
|
}
|
|
216
229
|
return out;
|
|
@@ -288,6 +301,7 @@ export class SessionHub {
|
|
|
288
301
|
projectSlug: turn.projectSlug,
|
|
289
302
|
jobId: turn.jobId,
|
|
290
303
|
running: turn.running,
|
|
304
|
+
startedAt: turn.startedAt,
|
|
291
305
|
});
|
|
292
306
|
}
|
|
293
307
|
recipients(turn) {
|
|
@@ -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
|
/**
|
|
@@ -373,6 +393,21 @@ export interface ChatActiveMessage {
|
|
|
373
393
|
/** The running turn's cancellable job id, when known. */
|
|
374
394
|
jobId: string | null;
|
|
375
395
|
running: boolean;
|
|
396
|
+
/**
|
|
397
|
+
* Epoch-ms the running turn began, from the session hub. The hub is the only
|
|
398
|
+
* thing that knows: a job record is written when a turn ENDS, and the
|
|
399
|
+
* transcript's timestamps are the model's rather than the run's. Because the
|
|
400
|
+
* server replays its whole running snapshot to every socket on connect, a
|
|
401
|
+
* client that reloads mid-turn still learns the true start — which is what
|
|
402
|
+
* lets a fleet readout say "running 12:04" instead of restarting at zero on
|
|
403
|
+
* every page load.
|
|
404
|
+
*
|
|
405
|
+
* Sent on the STOP frame too (`running: false`), where it describes the turn
|
|
406
|
+
* that just ended and a client has no use for it — the hub emits one frame
|
|
407
|
+
* shape for both transitions rather than branching. Optional on the wire so
|
|
408
|
+
* a client built against an older server still parses the frame.
|
|
409
|
+
*/
|
|
410
|
+
startedAt?: number;
|
|
376
411
|
};
|
|
377
412
|
}
|
|
378
413
|
/**
|
|
@@ -393,6 +428,16 @@ export interface ChatQueuedFlushedMessage {
|
|
|
393
428
|
* when the frame is just telling the client to clear a stale/already-sent copy.
|
|
394
429
|
*/
|
|
395
430
|
text?: string;
|
|
431
|
+
/**
|
|
432
|
+
* The attachments that rode the drained message (#728), so the client renders
|
|
433
|
+
* the auto-sent user bubble with its files exactly as a live send does. Only
|
|
434
|
+
* present alongside `text` (a clear-only frame carries neither).
|
|
435
|
+
*/
|
|
436
|
+
attachments?: Array<{
|
|
437
|
+
id: string;
|
|
438
|
+
filename: string;
|
|
439
|
+
kind?: string;
|
|
440
|
+
}>;
|
|
396
441
|
};
|
|
397
442
|
}
|
|
398
443
|
/**
|
|
@@ -414,6 +459,17 @@ export interface ChatQueuedStateMessage {
|
|
|
414
459
|
sessionId: string;
|
|
415
460
|
/** The chat's full queued text, or null when the slot is now empty. */
|
|
416
461
|
text: string | null;
|
|
462
|
+
/**
|
|
463
|
+
* The files staged on the slot (#728). Shared exactly like the text is: every
|
|
464
|
+
* attached client renders the same chips, so a file queued on one device is
|
|
465
|
+
* visible on the other rather than being invisible state that surfaces only
|
|
466
|
+
* when the drain sends it. Absent/empty when the slot holds no attachments.
|
|
467
|
+
*/
|
|
468
|
+
attachments?: Array<{
|
|
469
|
+
id: string;
|
|
470
|
+
filename: string;
|
|
471
|
+
kind?: string;
|
|
472
|
+
}>;
|
|
417
473
|
/**
|
|
418
474
|
* The slot's identity. A client adopts it as its own queue id, so its next
|
|
419
475
|
* edit updates this shared slot in place rather than appending beside it.
|
|
@@ -451,6 +507,17 @@ export interface ChatQueuedReturnedMessage {
|
|
|
451
507
|
sessionId: string;
|
|
452
508
|
/** The queued text, for the client to put back in its composer. */
|
|
453
509
|
text: string;
|
|
510
|
+
/**
|
|
511
|
+
* The files that were staged on the slot (#728), for the client to put back in
|
|
512
|
+
* its attachment tray. Stop returns the whole message, not just its prose —
|
|
513
|
+
* dropping the files here would be the same silent loss under a different name,
|
|
514
|
+
* and leaving them on the (now cleared) slot would strand them server-side.
|
|
515
|
+
*/
|
|
516
|
+
attachments?: Array<{
|
|
517
|
+
id: string;
|
|
518
|
+
filename: string;
|
|
519
|
+
kind?: string;
|
|
520
|
+
}>;
|
|
454
521
|
};
|
|
455
522
|
}
|
|
456
523
|
/**
|
|
@@ -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;
|