@edspencer/paddock 0.66.0 → 0.66.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/server/dist/app.d.ts +8 -0
- package/packages/server/dist/app.js +4 -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 +87 -0
- package/packages/server/dist/queued-message.js +214 -1
- package/packages/server/dist/routes/chats.js +27 -8
- package/packages/server/dist/routes/meta.js +32 -5
- package/packages/server/dist/routes/projects.js +71 -7
- package/packages/server/dist/session-hub.d.ts +69 -1
- package/packages/server/dist/session-hub.js +73 -1
- package/packages/server/dist/turn-interlock.js +8 -0
- package/packages/server/dist/ws-protocol.d.ts +128 -5
- package/packages/server/dist/ws-protocol.js +30 -13
- package/packages/server/dist/ws.d.ts +0 -52
- package/packages/server/dist/ws.js +545 -127
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DKzAHOhx.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-Bn6VPBdL.js → MarkdownRenderer-DDVO0zhl.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-CQmDBtd-.js → ProjectRedirect-WczXTV0_.js} +1 -1
- package/packages/web/dist/assets/ProjectView-Dd_myWpV.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-BW56zqke.js → ProjectsGrid-BeJj0q6Y.js} +1 -1
- package/packages/web/dist/assets/{arc-CGy6WkLl.js → arc-DXqVnx_r.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DJH1WIxY.js → architectureDiagram-3BPJPVTR-FgkfD1Bk.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Cufb_pvX.js → blockDiagram-GPEHLZMM-WwkIIJsI.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-glqeg1Ky.js → c4Diagram-AAUBKEIU-BIzkTibx.js} +1 -1
- package/packages/web/dist/assets/channel-DUsOl5_p.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-SOFTHYgh.js → chunk-2J33WTMH-C4TBH3R6.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DFxFAiG8.js → chunk-4BX2VUAB-DTKJZlRM.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-CEEDxZVG.js → chunk-55IACEB6-BC9_Qc8h.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM--3eiptsh.js → chunk-727SXJPM-D3gQ4rP3.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DIHIugCo.js → chunk-AQP2D5EJ-My2AXBvz.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BmxWZl5c.js → chunk-FMBD7UC4-D09zE6Ow.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-hMHtJs_I.js → chunk-ND2GUHAM-CUSgeCLl.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CRnnasyy.js → chunk-QZHKN3VN-B6jVPYgy.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CIdDKEqH.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CIdDKEqH.js +2 -0
- package/packages/web/dist/assets/{core-CGW2CVAY.js → core-DPY0seI-.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-kcl9qSfK.js → cose-bilkent-S5V4N54A-BasCiARv.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-BiLsFx1W.js → dagre-BM42HDAG-Cimn11Qe.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-BcIpoY66.js → diagram-2AECGRRQ-fe9cDHyn.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-BK5p5kJa.js → diagram-5GNKFQAL-T0tQlfbl.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-BmJYalCP.js → diagram-KO2AKTUF-dLORrSiu.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BHs0SWEM.js → diagram-LMA3HP47-iUMeaR-J.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-CSs8hie4.js → diagram-OG6HWLK6-oEH6olHK.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-4qd_Td27.js → erDiagram-TEJ5UH35-VtQqF9SY.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CstUgr7V.js → flowDiagram-I6XJVG4X-wvF2EkJa.js} +1 -1
- package/packages/web/dist/assets/{format-B_kAvAW3.js → format-CvRqOKZU.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DTn9OuIc.js → ganttDiagram-6RSMTGT7-ng4qJ0E1.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-E_bCor2O.js → gitGraphDiagram-PVQCEYII-BV4aWy8k.js} +1 -1
- package/packages/web/dist/assets/index-DGNSeqL-.js +71 -0
- package/packages/web/dist/assets/index-DMRbU1Jl.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-foRUrIOQ.js → infoDiagram-5YYISTIA-BpGTxAjU.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CTON8v5t.js → ishikawaDiagram-YF4QCWOH-Cj36LyoS.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-D-qwGjr7.js → journeyDiagram-JHISSGLW-0vNlg3um.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-5l4y83rE.js → kanban-definition-UN3LZRKU-wijkEDaR.js} +1 -1
- package/packages/web/dist/assets/{linear-B1GiUWpI.js → linear-LqRymMD_.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-btQIOU6d.js → mermaid.core-DPrhSs95.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DduczfMv.js → mindmap-definition-RKZ34NQL-en-uGqgC.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D86uGusc.js → pieDiagram-4H26LBE5-DKBZ2Vnx.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js → quadrantDiagram-W4KKPZXB--g-WtxyL.js} +1 -1
- package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-C9aZJK93.js → requirementDiagram-4Y6WPE33-Dl4eYxDI.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-b__W6SAJ.js → sankeyDiagram-5OEKKPKP-7lt4gqSw.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Lr4Df8kX.js → sequenceDiagram-3UESZ5HK-COgq-dXg.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-R0XxGxD5.js → stateDiagram-AJRCARHV-CL6XC79S.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BDlRyphY.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D42Je9LW.js → timeline-definition-PNZ67QCA-C5egKeGT.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-KCu14-QB.js → vennDiagram-CIIHVFJN-C_UhtC8u.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-Cfir3V9o.js → wardley-L42UT6IY-Bj9chUqz.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BkXjbTXu.js → wardleyDiagram-YWT4CUSO-D7VnZoRp.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BVT60Pln.js → xychartDiagram-2RQKCTM6-VrWLDoi4.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DBrwtZjx.js +0 -2
- package/packages/web/dist/assets/ProjectView-BVKu9oIg.js +0 -13
- package/packages/web/dist/assets/channel-C4kPYn_d.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCg6gTVh.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCg6gTVh.js +0 -2
- package/packages/web/dist/assets/index-B-QAOq9a.js +0 -71
- package/packages/web/dist/assets/index-B3K5nsLe.css +0 -1
- package/packages/web/dist/assets/queued-LamEU0GH.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-i2pZN-iR.js +0 -2
|
@@ -36,10 +36,10 @@ import { PROJECT_SCHEMA_VERSION, SCHEMA_VERSION_KEY, projectSchemaSkip, } from "
|
|
|
36
36
|
// them at `./projects.js` — the same one-place-to-import discipline as before.
|
|
37
37
|
import { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor, } from "./project-mime.js";
|
|
38
38
|
export { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor };
|
|
39
|
-
import { SLUG_RE, ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, today, claudeTemplate, ProjectError, } from "./project-paths.js";
|
|
40
|
-
export { ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
|
|
39
|
+
import { SLUG_RE, ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, today, claudeTemplate, ProjectError, } from "./project-paths.js";
|
|
40
|
+
export { ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
|
|
41
41
|
import { gitRemotes, normalizeRemote } from "./adoptable.js";
|
|
42
|
-
import { normalizeLinks, } from "./project-types.js";
|
|
42
|
+
import { normalizeLinks, PATCHABLE_KEYS, } from "./project-types.js";
|
|
43
43
|
import * as projectFiles from "./project-files.js";
|
|
44
44
|
// Barrel: re-export all type declarations + normalizeLinks + the config-type
|
|
45
45
|
// re-exports (PaddockSchedule/PaddockHook/PaddockTrigger/RecoveryOverride/
|
|
@@ -221,7 +221,12 @@ export class ProjectStore {
|
|
|
221
221
|
async rmInsideRoot(target) {
|
|
222
222
|
const resolved = path.resolve(target);
|
|
223
223
|
const root = path.resolve(this.root);
|
|
224
|
-
|
|
224
|
+
// A projects root of `/` would make every path on the box "inside" it and
|
|
225
|
+
// turn this guard into a no-op. That was accidentally safe before #719 (when
|
|
226
|
+
// `isPathInside(x, "/")` was false for everything); now that containment
|
|
227
|
+
// reports the truth, refuse the degenerate root explicitly rather than
|
|
228
|
+
// relying on a bug to hold the line.
|
|
229
|
+
if (root === path.parse(root).root || resolved === root || !isPathInside(resolved, root)) {
|
|
225
230
|
throw new ProjectError(`Refusing to delete outside the projects root: ${target}`, "invalid");
|
|
226
231
|
}
|
|
227
232
|
await fs.rm(resolved, { recursive: true, force: true });
|
|
@@ -259,6 +264,9 @@ export class ProjectStore {
|
|
|
259
264
|
* - a relative path (a cwd must be unambiguous — it is baked into every
|
|
260
265
|
* transcript path, and resolving it against the server's cwd is a footgun);
|
|
261
266
|
* - a path that exists but isn't a directory;
|
|
267
|
+
* - the filesystem root, or a path inside a system directory (issue #720) —
|
|
268
|
+
* see {@link isSystemPath} for the floor and why it is a denylist rather
|
|
269
|
+
* than a configurable allowed root;
|
|
262
270
|
* - a path inside `projectsRoot` or the data dir — that is Paddock's own
|
|
263
271
|
* state, and pointing at it re-creates the nesting this feature exists to
|
|
264
272
|
* avoid (plus `remove()` would then really delete it);
|
|
@@ -297,6 +305,18 @@ export class ProjectStore {
|
|
|
297
305
|
throw new ProjectError(`Project path is not a directory: ${input}`, "not_directory");
|
|
298
306
|
}
|
|
299
307
|
}
|
|
308
|
+
// The system-path floor (issue #720), applied to BOTH spellings.
|
|
309
|
+
//
|
|
310
|
+
// The canonicalised path is the one that matters — a symlink pointing at
|
|
311
|
+
// `/etc` must be refused for where it really goes. But the path AS WRITTEN
|
|
312
|
+
// has to be checked too, because `/proc/self/cwd` canonicalises to an
|
|
313
|
+
// ordinary directory (Paddock's own checkout) and so passes the resolved
|
|
314
|
+
// check: a `/proc` path is process-relative and non-durable, which a cwd
|
|
315
|
+
// baked into every transcript path can never be.
|
|
316
|
+
if (isSystemPath(resolved) || isSystemPath(input)) {
|
|
317
|
+
throw new ProjectError(`Project path is a system directory and cannot back a project: ${input}` +
|
|
318
|
+
(resolved === path.resolve(input) ? "" : ` (resolves to ${resolved})`), "invalid");
|
|
319
|
+
}
|
|
300
320
|
if (isPathInside(resolved, this.root)) {
|
|
301
321
|
throw new ProjectError(`Project path must be outside the projects root (${this.root}): ${input}`, "invalid");
|
|
302
322
|
}
|
|
@@ -699,30 +719,64 @@ export class ProjectStore {
|
|
|
699
719
|
// can't express "delete this field", which is how an override is cleared back
|
|
700
720
|
// to inherit.
|
|
701
721
|
const { driveMode: driveModePatch, maxSpawnDepth: maxSpawnDepthPatch, hooksMcpEnabled: hooksMcpPatch, recovery: recoveryPatch, attachments: attachmentsPatch, curation: curationPatch, models: modelsPatch, ...rest } = patch;
|
|
722
|
+
// Take ONLY the allowlisted keys off the body (issue #721). `rest` is spread
|
|
723
|
+
// from an untrusted request body, so the previous `...rest` persisted any key
|
|
724
|
+
// a caller invented — verbatim, unbounded, into a file re-parsed on every
|
|
725
|
+
// project listing — and a key seeded with garbage today is a landmine for the
|
|
726
|
+
// release that starts honouring it.
|
|
727
|
+
const unknown = [];
|
|
728
|
+
const patched = {};
|
|
729
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
730
|
+
if (value === undefined)
|
|
731
|
+
continue;
|
|
732
|
+
if (!PATCHABLE_KEYS.includes(key)) {
|
|
733
|
+
unknown.push(key);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
patched[key] = value;
|
|
737
|
+
}
|
|
738
|
+
if (unknown.length > 0) {
|
|
739
|
+
this.log.warn(`Ignoring unpatchable key(s) in PATCH of project "${slug}": ${unknown.join(", ")}`);
|
|
740
|
+
}
|
|
702
741
|
const next = {
|
|
703
742
|
...this.stripDto(current),
|
|
704
|
-
...
|
|
743
|
+
...patched,
|
|
705
744
|
slug: current.slug, // immutable
|
|
706
745
|
started: current.started, // immutable
|
|
707
746
|
updated: today(),
|
|
708
747
|
};
|
|
709
|
-
// `path` and `
|
|
710
|
-
//
|
|
711
|
-
//
|
|
748
|
+
// `path`, `managed` and `repo` are immutable — the THREE fields that feed
|
|
749
|
+
// `workingDirFor()` and therefore decide the keeper's cwd. The allowlist above
|
|
750
|
+
// already keeps them out of `patched`; re-asserting them from `current` here
|
|
751
|
+
// is deliberate belt-and-braces at the point the record is built, so a key
|
|
752
|
+
// later added to the allowlist cannot silently move a cwd.
|
|
712
753
|
//
|
|
713
|
-
// For `path` both directions matter: a project must not be
|
|
714
|
-
// is baked into every transcript path, so moving it
|
|
715
|
-
// one must not be
|
|
716
|
-
// keeper a cwd somewhere else on the box that never went
|
|
717
|
-
// `validatePath`. `managed` is re-asserted
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
754
|
+
// For `path` both directions matter (issue #206): a project must not be
|
|
755
|
+
// RE-POINTED (its cwd is baked into every transcript path, so moving it
|
|
756
|
+
// strands the history), and one must not be GIVEN a path by a stray key,
|
|
757
|
+
// which would hand its keeper a cwd somewhere else on the box that never went
|
|
758
|
+
// through `validatePath`. `managed` is re-asserted for the same reason —
|
|
759
|
+
// flipping it moves where the curated trio lives and either starts the sweeper
|
|
760
|
+
// writing into a checkout or orphans the existing notes.
|
|
761
|
+
//
|
|
762
|
+
// `repo` was the one that got missed (issue #718). It is documented immutable
|
|
763
|
+
// (`ProjectYaml.repo`: "Set at creation; immutable thereafter"), `create()` and
|
|
764
|
+
// `promote()` both validate it with `isValidRepoUrl()`, and `update()` did
|
|
765
|
+
// neither: `PATCH {"repo":"not a url at all"}` returned 200 and relocated
|
|
766
|
+
// `workingDir` AND `contentDir` to `<dir>/not-a-url-at-all`, a directory that
|
|
767
|
+
// does not exist — every subsequent turn then hung 60s waiting for a session
|
|
768
|
+
// file and failed, with the existing chats stranded on the old cwd. Acquiring a
|
|
769
|
+
// repo for an existing project is what `promote()` is for; changing a backing
|
|
770
|
+
// store is a migration, not a PATCH (see #708 for what a silent location flip
|
|
771
|
+
// does to history).
|
|
722
772
|
if (current.path)
|
|
723
773
|
next.path = current.path;
|
|
724
774
|
else
|
|
725
775
|
delete next.path;
|
|
776
|
+
if (current.repo)
|
|
777
|
+
next.repo = current.repo;
|
|
778
|
+
else
|
|
779
|
+
delete next.repo;
|
|
726
780
|
next.managed = current.managed;
|
|
727
781
|
if (driveModePatch === null) {
|
|
728
782
|
// Clear the per-project override -> inherit the global default (issue #122).
|
|
@@ -1,8 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One identity a chat's queued message has been known by.
|
|
3
|
+
*
|
|
4
|
+
* `id` is OPAQUE and compared only for EQUALITY — never ordered — so a client with
|
|
5
|
+
* a skewed clock can't poison anything (#736). Two kinds of id land here: the one a
|
|
6
|
+
* CLIENT sent with its `chat:set_queue`, and the slot VERSION the server minted in
|
|
7
|
+
* response and broadcast back. A client re-asserting a queue on reload sends one of
|
|
8
|
+
* the two, which is exactly what makes the drain able to recognise it (see
|
|
9
|
+
* `drainQueue` in ws.ts).
|
|
10
|
+
*/
|
|
11
|
+
export interface QueuedPart {
|
|
12
|
+
id: string;
|
|
13
|
+
text: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A composer attachment staged on the slot (#728) — the same lightweight ref the
|
|
17
|
+
* `chat:send` path carries. The bytes live in the AttachmentStore; only the id,
|
|
18
|
+
* filename and kind travel with the queued message.
|
|
19
|
+
*/
|
|
20
|
+
export interface QueuedAttachment {
|
|
21
|
+
id: string;
|
|
22
|
+
filename: string;
|
|
23
|
+
kind?: string;
|
|
24
|
+
}
|
|
1
25
|
/** A queued message awaiting auto-send. */
|
|
2
26
|
export interface QueuedMessage {
|
|
27
|
+
/**
|
|
28
|
+
* The slot's full text — what gets sent. May be `""` when the slot holds only
|
|
29
|
+
* attachments (#728: an attachment-only message is valid, #328).
|
|
30
|
+
*/
|
|
3
31
|
text: string;
|
|
32
|
+
/** SERVER-stamped enqueue time. Informational — never load-bearing (#736). */
|
|
4
33
|
createdAtMs: number;
|
|
34
|
+
/**
|
|
35
|
+
* The slot's current VERSION, minted on every write and broadcast to every
|
|
36
|
+
* attached client. A client echoes back the version it last saw, which is how
|
|
37
|
+
* the server can tell "this client is editing the slot it can see" from "this
|
|
38
|
+
* client composed a queue of its own, not knowing about the other one" (#629).
|
|
39
|
+
*/
|
|
40
|
+
id?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Every (id, text) this slot has been known by — the dedup ledger. Absent on an
|
|
43
|
+
* entry written by an older server; readers treat that as one legacy identity.
|
|
44
|
+
*/
|
|
45
|
+
parts?: QueuedPart[];
|
|
46
|
+
/**
|
|
47
|
+
* Files staged in the composer that ride this message when it drains (#728).
|
|
48
|
+
* Merged as a UNION BY ID so a write can only ever add: a client re-asserting
|
|
49
|
+
* its queue after a reload has an empty tray by then, and must not be able to
|
|
50
|
+
* wipe the files off the shared slot.
|
|
51
|
+
*/
|
|
52
|
+
attachments?: QueuedAttachment[];
|
|
5
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Union two attachment lists by id, preserving order and preferring the entry
|
|
56
|
+
* already on the slot (its filename/kind is the one every client has been shown).
|
|
57
|
+
* Bounded by {@link MAX_SLOT_ATTACHMENTS}.
|
|
58
|
+
*/
|
|
59
|
+
export declare function unionAttachments(current: QueuedAttachment[] | undefined, incoming: QueuedAttachment[] | undefined): QueuedAttachment[];
|
|
60
|
+
/** Does this slot hold anything worth sending? Text, attachments, or both. */
|
|
61
|
+
export declare function hasQueuedContent(m: QueuedMessage | null | undefined): boolean;
|
|
62
|
+
/** The identities of an entry, synthesising one for a legacy entry without any. */
|
|
63
|
+
export declare function partsOf(m: QueuedMessage): QueuedPart[];
|
|
6
64
|
export declare class QueuedMessageStore {
|
|
7
65
|
private readonly stateFile;
|
|
8
66
|
/** In-memory map of key -> QueuedMessage (loaded once, written through on change). */
|
|
@@ -30,6 +88,35 @@ export declare class QueuedMessageStore {
|
|
|
30
88
|
* Setting `null` deletes the key, keeping the file compact.
|
|
31
89
|
*/
|
|
32
90
|
set(agent: string, sessionId: string, message: QueuedMessage | null): Promise<void>;
|
|
91
|
+
/**
|
|
92
|
+
* Write one client's queue into a chat's single slot, MERGING rather than
|
|
93
|
+
* overwriting (#629), and return the resulting entry. The read-modify-write
|
|
94
|
+
* happens with no `await` between the load and the mutation, so two clients
|
|
95
|
+
* queueing at once can't lose one another's text the way the old unconditional
|
|
96
|
+
* `set` overwrite did.
|
|
97
|
+
*
|
|
98
|
+
* `incoming.id` is the identity the client is writing under — either the slot
|
|
99
|
+
* VERSION it last saw (broadcast to it via `chat:queued_state`) or, if it has
|
|
100
|
+
* never seen one, its own queue id. That is what distinguishes the two cases:
|
|
101
|
+
*
|
|
102
|
+
* - **it matches the current version** ⇒ the client can see the slot as it is
|
|
103
|
+
* now, so its text IS the whole slot: replace. This covers the ordinary
|
|
104
|
+
* single-client edit/append (#245 stable identity) and any client that has
|
|
105
|
+
* caught up with a merge.
|
|
106
|
+
* - **it doesn't** ⇒ the client composed this queue without knowing the
|
|
107
|
+
* slot's current contents (a second device, or a tab that missed the
|
|
108
|
+
* broadcast): APPEND, so neither message is lost.
|
|
109
|
+
*
|
|
110
|
+
* A write whose text the slot has already been known by is a re-assert — a
|
|
111
|
+
* reconnecting client pushing its stored copy — and is a no-op, so reconnecting
|
|
112
|
+
* can't duplicate text into the slot.
|
|
113
|
+
*
|
|
114
|
+
* `nowMs` stamps a fresh slot's `createdAtMs` SERVER-side; an existing slot keeps
|
|
115
|
+
* the one it has. The client's clock never enters the store (#736).
|
|
116
|
+
*/
|
|
117
|
+
upsert(agent: string, sessionId: string, incoming: QueuedPart & {
|
|
118
|
+
attachments?: QueuedAttachment[];
|
|
119
|
+
}, nowMs: number, mintVersion: () => string): Promise<QueuedMessage | null>;
|
|
33
120
|
/** Write-through, serialised so overlapping writes can't corrupt the file. */
|
|
34
121
|
private persist;
|
|
35
122
|
}
|
|
@@ -15,6 +15,26 @@
|
|
|
15
15
|
* When a turn completes, the server checks for a queued message, auto-sends it as
|
|
16
16
|
* the next message, and deletes the stored entry. The web client receives a
|
|
17
17
|
* `chat:queued_flushed` frame to clear its localStorage.
|
|
18
|
+
*
|
|
19
|
+
* ## One slot, many contributors (#629)
|
|
20
|
+
*
|
|
21
|
+
* The slot is per CHAT, not per client — a chat has one "next message", the same
|
|
22
|
+
* model the composer renders. It used to be a bare overwrite, so a second client
|
|
23
|
+
* queueing on the same chat silently destroyed the first client's text (and the
|
|
24
|
+
* first client's transcript then rendered a user bubble nobody there had typed).
|
|
25
|
+
*
|
|
26
|
+
* The slot now holds an ordered list of {@link QueuedPart}s: one per contributing
|
|
27
|
+
* client queue, identified by that client's opaque queue id. A client updating ITS
|
|
28
|
+
* OWN part replaces it in place (an edit/append — #245's stable identity); a
|
|
29
|
+
* DIFFERENT client's queue appends a new part. `text` is the joined result and
|
|
30
|
+
* stays the thing that gets sent, so nothing downstream — nor an older reader of
|
|
31
|
+
* this file — needs to know about parts.
|
|
32
|
+
*
|
|
33
|
+
* The parts double as the drain's dedup ledger. A client that never saw the
|
|
34
|
+
* `chat:queued_flushed` clear (it is broadcast un-buffered) re-asserts its own
|
|
35
|
+
* (id, text) on reload; matching that against the parts actually flushed is what
|
|
36
|
+
* lets the drain tell a stale re-assert from a genuinely new message WITHOUT
|
|
37
|
+
* comparing wall clocks (#736). See `drainQueue` in ws.ts.
|
|
18
38
|
*/
|
|
19
39
|
import { promises as fs } from "node:fs";
|
|
20
40
|
import path from "node:path";
|
|
@@ -25,6 +45,97 @@ const KEY_SEP = "\u0000";
|
|
|
25
45
|
function keyOf(agent, sessionId) {
|
|
26
46
|
return `${agent}${KEY_SEP}${sessionId}`;
|
|
27
47
|
}
|
|
48
|
+
/** Joiner when a second client's queue is merged in — what the composer uses. */
|
|
49
|
+
const MERGE_SEP = "\n";
|
|
50
|
+
/** How many (id, text) identities one slot remembers. Bounds the sidecar entry. */
|
|
51
|
+
const MAX_KNOWN = 24;
|
|
52
|
+
/**
|
|
53
|
+
* How many attachments one slot can hold (#728). The composer caps a single
|
|
54
|
+
* message at 10 by default, but the slot is shared and merges additively, so two
|
|
55
|
+
* devices could otherwise stack refs into the sidecar without bound. Well above
|
|
56
|
+
* any real queued follow-up; the excess is dropped rather than persisted.
|
|
57
|
+
*/
|
|
58
|
+
const MAX_SLOT_ATTACHMENTS = 20;
|
|
59
|
+
/**
|
|
60
|
+
* Union two attachment lists by id, preserving order and preferring the entry
|
|
61
|
+
* already on the slot (its filename/kind is the one every client has been shown).
|
|
62
|
+
* Bounded by {@link MAX_SLOT_ATTACHMENTS}.
|
|
63
|
+
*/
|
|
64
|
+
export function unionAttachments(current, incoming) {
|
|
65
|
+
const out = [];
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
for (const a of [...(current ?? []), ...(incoming ?? [])]) {
|
|
68
|
+
if (!a || typeof a.id !== "string" || !a.id || seen.has(a.id))
|
|
69
|
+
continue;
|
|
70
|
+
seen.add(a.id);
|
|
71
|
+
out.push(a.kind === undefined ? { id: a.id, filename: a.filename } : { ...a });
|
|
72
|
+
if (out.length >= MAX_SLOT_ATTACHMENTS)
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** Narrow a parsed value to a well-formed attachment list (dropping junk). */
|
|
78
|
+
function sanitizeAttachments(v) {
|
|
79
|
+
if (!Array.isArray(v))
|
|
80
|
+
return [];
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const a of v) {
|
|
83
|
+
if (!a || typeof a !== "object")
|
|
84
|
+
continue;
|
|
85
|
+
const o = a;
|
|
86
|
+
if (typeof o.id !== "string" || !o.id)
|
|
87
|
+
continue;
|
|
88
|
+
if (typeof o.filename !== "string" || !o.filename)
|
|
89
|
+
continue;
|
|
90
|
+
out.push({
|
|
91
|
+
id: o.id,
|
|
92
|
+
filename: o.filename,
|
|
93
|
+
...(typeof o.kind === "string" ? { kind: o.kind } : {}),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
/** Does this slot hold anything worth sending? Text, attachments, or both. */
|
|
99
|
+
export function hasQueuedContent(m) {
|
|
100
|
+
if (!m)
|
|
101
|
+
return false;
|
|
102
|
+
return m.text.length > 0 || (m.attachments?.length ?? 0) > 0;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Fold a write from a client that ISN'T on the slot's current version into the
|
|
106
|
+
* slot's text.
|
|
107
|
+
*
|
|
108
|
+
* Usually that client is simply appending to its own queue: it holds text the slot
|
|
109
|
+
* already contains and has added a line to the end of it. That is the ordinary
|
|
110
|
+
* single-client append — the composer keeps one queue id across an append (#245),
|
|
111
|
+
* and an older client sends only its enqueue `ts`, so neither ever carries the
|
|
112
|
+
* current version. It also covers the plain race of appending faster than the
|
|
113
|
+
* broadcast round trip. In every one of those, the client's text EXTENDS something
|
|
114
|
+
* the slot already holds, so we substitute in place rather than appending — or the
|
|
115
|
+
* client's own earlier text would appear twice.
|
|
116
|
+
*
|
|
117
|
+
* Otherwise the client queued without knowing what was already there (a second
|
|
118
|
+
* device), and its text is appended so neither message is lost (#629).
|
|
119
|
+
*/
|
|
120
|
+
function mergeInto(current, known, incoming) {
|
|
121
|
+
// The most specific thing the incoming text extends — longest first, since a
|
|
122
|
+
// queue that has been appended to is known by every prefix it has passed through.
|
|
123
|
+
const extended = known
|
|
124
|
+
.filter((p) => p.text.length > 0 && incoming.startsWith(`${p.text}${MERGE_SEP}`))
|
|
125
|
+
.sort((a, b) => b.text.length - a.text.length)[0];
|
|
126
|
+
const at = extended ? current.indexOf(extended.text) : -1;
|
|
127
|
+
if (at < 0)
|
|
128
|
+
return `${current}${MERGE_SEP}${incoming}`;
|
|
129
|
+
// Splice rather than String.replace: the texts are user prose and `$&`-style
|
|
130
|
+
// patterns in a replacement string would be interpreted.
|
|
131
|
+
return current.slice(0, at) + incoming + current.slice(at + extended.text.length);
|
|
132
|
+
}
|
|
133
|
+
/** The identities of an entry, synthesising one for a legacy entry without any. */
|
|
134
|
+
export function partsOf(m) {
|
|
135
|
+
if (m.parts && m.parts.length > 0)
|
|
136
|
+
return m.parts;
|
|
137
|
+
return [{ id: m.id ?? `legacy:${m.createdAtMs}`, text: m.text }];
|
|
138
|
+
}
|
|
28
139
|
export class QueuedMessageStore {
|
|
29
140
|
stateFile;
|
|
30
141
|
/** In-memory map of key -> QueuedMessage (loaded once, written through on change). */
|
|
@@ -49,7 +160,29 @@ export class QueuedMessageStore {
|
|
|
49
160
|
typeof v === "object" &&
|
|
50
161
|
typeof v.text === "string" &&
|
|
51
162
|
typeof v.createdAtMs === "number") {
|
|
52
|
-
|
|
163
|
+
const entry = v;
|
|
164
|
+
// `parts` is optional (an older server wrote entries without it) and
|
|
165
|
+
// corruption-tolerant like everything else here: a malformed parts
|
|
166
|
+
// array is dropped, leaving a legacy single-part entry, not a throw.
|
|
167
|
+
const raw = v.parts;
|
|
168
|
+
const parts = Array.isArray(raw)
|
|
169
|
+
? raw.filter((p) => Boolean(p) &&
|
|
170
|
+
typeof p === "object" &&
|
|
171
|
+
typeof p.id === "string" &&
|
|
172
|
+
typeof p.text === "string")
|
|
173
|
+
: [];
|
|
174
|
+
const id = typeof v.id === "string" ? entry.id : undefined;
|
|
175
|
+
// Same tolerance for the staged attachments (#728): malformed entries
|
|
176
|
+
// are dropped, never thrown on — a corrupt sidecar must not be able to
|
|
177
|
+
// stop the queue loading.
|
|
178
|
+
const attachments = sanitizeAttachments(v.attachments);
|
|
179
|
+
map.set(k, {
|
|
180
|
+
text: entry.text,
|
|
181
|
+
createdAtMs: entry.createdAtMs,
|
|
182
|
+
...(id ? { id } : {}),
|
|
183
|
+
...(parts.length > 0 ? { parts } : {}),
|
|
184
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
185
|
+
});
|
|
53
186
|
}
|
|
54
187
|
}
|
|
55
188
|
}
|
|
@@ -102,6 +235,86 @@ export class QueuedMessageStore {
|
|
|
102
235
|
}
|
|
103
236
|
await this.persist(map);
|
|
104
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Write one client's queue into a chat's single slot, MERGING rather than
|
|
240
|
+
* overwriting (#629), and return the resulting entry. The read-modify-write
|
|
241
|
+
* happens with no `await` between the load and the mutation, so two clients
|
|
242
|
+
* queueing at once can't lose one another's text the way the old unconditional
|
|
243
|
+
* `set` overwrite did.
|
|
244
|
+
*
|
|
245
|
+
* `incoming.id` is the identity the client is writing under — either the slot
|
|
246
|
+
* VERSION it last saw (broadcast to it via `chat:queued_state`) or, if it has
|
|
247
|
+
* never seen one, its own queue id. That is what distinguishes the two cases:
|
|
248
|
+
*
|
|
249
|
+
* - **it matches the current version** ⇒ the client can see the slot as it is
|
|
250
|
+
* now, so its text IS the whole slot: replace. This covers the ordinary
|
|
251
|
+
* single-client edit/append (#245 stable identity) and any client that has
|
|
252
|
+
* caught up with a merge.
|
|
253
|
+
* - **it doesn't** ⇒ the client composed this queue without knowing the
|
|
254
|
+
* slot's current contents (a second device, or a tab that missed the
|
|
255
|
+
* broadcast): APPEND, so neither message is lost.
|
|
256
|
+
*
|
|
257
|
+
* A write whose text the slot has already been known by is a re-assert — a
|
|
258
|
+
* reconnecting client pushing its stored copy — and is a no-op, so reconnecting
|
|
259
|
+
* can't duplicate text into the slot.
|
|
260
|
+
*
|
|
261
|
+
* `nowMs` stamps a fresh slot's `createdAtMs` SERVER-side; an existing slot keeps
|
|
262
|
+
* the one it has. The client's clock never enters the store (#736).
|
|
263
|
+
*/
|
|
264
|
+
async upsert(agent, sessionId, incoming, nowMs, mintVersion) {
|
|
265
|
+
const map = await this.ensureLoaded();
|
|
266
|
+
const key = keyOf(agent, sessionId);
|
|
267
|
+
const current = map.get(key) ?? null;
|
|
268
|
+
// Attachments merge additively and independently of the text (#728): a write
|
|
269
|
+
// can add files, never remove them. Computed first so an attachments-only
|
|
270
|
+
// write — a valid message with no prose (#328) — has something to write even
|
|
271
|
+
// though every text rule below leaves the text alone.
|
|
272
|
+
const attachments = unionAttachments(current?.attachments, incoming.attachments);
|
|
273
|
+
const gainedAttachments = attachments.length > (current?.attachments?.length ?? 0);
|
|
274
|
+
const hasText = incoming.text.trim().length > 0;
|
|
275
|
+
if (!hasText && !gainedAttachments)
|
|
276
|
+
return current;
|
|
277
|
+
const known = current ? partsOf(current) : [];
|
|
278
|
+
// Already folded in under some identity ⇒ a re-assert, not new text. Still
|
|
279
|
+
// worth writing if it brought new files with it.
|
|
280
|
+
if (hasText && known.some((p) => p.text === incoming.text) && !gainedAttachments) {
|
|
281
|
+
return current;
|
|
282
|
+
}
|
|
283
|
+
const textIsNew = hasText && !known.some((p) => p.text === incoming.text);
|
|
284
|
+
const text = !current
|
|
285
|
+
? incoming.text
|
|
286
|
+
: !textIsNew
|
|
287
|
+
? // Nothing new to say — an attachments-only write, or a re-assert that
|
|
288
|
+
// brought files. The slot's prose is untouched.
|
|
289
|
+
current.text
|
|
290
|
+
: incoming.id === current.id
|
|
291
|
+
? // The client can see the slot as it stands, so its text IS the slot.
|
|
292
|
+
incoming.text
|
|
293
|
+
: mergeInto(current.text, known, incoming.text);
|
|
294
|
+
const version = mintVersion();
|
|
295
|
+
// Remember BOTH identities this write creates: what the client sent (which it
|
|
296
|
+
// will re-assert on reload) and the version we hand back (which it will
|
|
297
|
+
// re-assert once it adopts the broadcast). Either one reaching a later drain
|
|
298
|
+
// has to be recognisable as already-flushed.
|
|
299
|
+
// An attachments-only write contributes no text, so it contributes no identity
|
|
300
|
+
// to remember either — an empty-text part would match nothing and only crowd
|
|
301
|
+
// the ledger. The slot version is always recorded.
|
|
302
|
+
const parts = [
|
|
303
|
+
...known,
|
|
304
|
+
...(hasText ? [{ id: incoming.id, text: incoming.text }] : []),
|
|
305
|
+
{ id: version, text },
|
|
306
|
+
].slice(-MAX_KNOWN);
|
|
307
|
+
const next = {
|
|
308
|
+
text,
|
|
309
|
+
createdAtMs: current?.createdAtMs ?? nowMs,
|
|
310
|
+
id: version,
|
|
311
|
+
parts,
|
|
312
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
313
|
+
};
|
|
314
|
+
map.set(key, next);
|
|
315
|
+
await this.persist(map);
|
|
316
|
+
return next;
|
|
317
|
+
}
|
|
105
318
|
/** Write-through, serialised so overlapping writes can't corrupt the file. */
|
|
106
319
|
persist(map) {
|
|
107
320
|
this.writing = this.writing.then(async () => {
|
|
@@ -134,14 +134,17 @@ 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: {
|
|
@@ -632,6 +635,13 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
632
635
|
}
|
|
633
636
|
await cleanupAttachments(agent, req.params.sessionId);
|
|
634
637
|
const { removed, retained } = await herdctl.deleteSession(agent, req.params.sessionId);
|
|
638
|
+
// #732: and the chat's herdctl job records, which are what feeds the
|
|
639
|
+
// sidebar unread badge. Left behind, they kept the badge counting a chat
|
|
640
|
+
// there was nothing left to open to clear. Only when the transcript was
|
|
641
|
+
// actually REMOVED — see `purgeSessionJobs` for why a `retained`
|
|
642
|
+
// (released) chat keeps its records.
|
|
643
|
+
if (removed)
|
|
644
|
+
await herdctl.purgeSessionJobs([req.params.sessionId]);
|
|
635
645
|
// Drop any archived/starred/unread/detached flag so a future session id
|
|
636
646
|
// can't inherit it. (Read-state watermarks, other users' unread overrides,
|
|
637
647
|
// provenance and any queued message are deliberately NOT swept here — that
|
|
@@ -1248,6 +1258,10 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1248
1258
|
const user = readStateUser(req);
|
|
1249
1259
|
const removed = [];
|
|
1250
1260
|
const failed = [];
|
|
1261
|
+
// The subset whose transcript was genuinely deleted — the ids whose job
|
|
1262
|
+
// records may be purged (#732). Narrower than `removed`, which also
|
|
1263
|
+
// holds RELEASED chats: those are still listed, so their records stay.
|
|
1264
|
+
const deleted = [];
|
|
1251
1265
|
for (const sessionId of sessionIds) {
|
|
1252
1266
|
try {
|
|
1253
1267
|
// #731: same interlock as the single delete, per id. A chat whose
|
|
@@ -1270,11 +1284,16 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1270
1284
|
removed.push(sessionId);
|
|
1271
1285
|
else
|
|
1272
1286
|
failed.push(sessionId);
|
|
1287
|
+
if (gone.removed)
|
|
1288
|
+
deleted.push(sessionId);
|
|
1273
1289
|
}
|
|
1274
1290
|
catch {
|
|
1275
1291
|
failed.push(sessionId);
|
|
1276
1292
|
}
|
|
1277
1293
|
}
|
|
1294
|
+
// ONE jobs-dir pass for the whole batch (#732) — purging per chat would
|
|
1295
|
+
// re-scan the shared directory once per id, up to 500 times.
|
|
1296
|
+
await herdctl.purgeSessionJobs(deleted);
|
|
1278
1297
|
// Drop the archived/starred/unread/detached flags of the chats that were
|
|
1279
1298
|
// ACTUALLY removed, so a recycled session id can't inherit stale state.
|
|
1280
1299
|
//
|
|
@@ -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
|