@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.
Files changed (94) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.d.ts +8 -0
  3. package/packages/server/dist/app.js +4 -1
  4. package/packages/server/dist/herdctl-jobs.d.ts +20 -0
  5. package/packages/server/dist/herdctl-jobs.js +94 -0
  6. package/packages/server/dist/herdctl.d.ts +46 -0
  7. package/packages/server/dist/herdctl.js +58 -0
  8. package/packages/server/dist/instance-config.d.ts +56 -5
  9. package/packages/server/dist/instance-config.js +206 -24
  10. package/packages/server/dist/project-paths.d.ts +46 -1
  11. package/packages/server/dist/project-paths.js +66 -2
  12. package/packages/server/dist/project-types.d.ts +23 -0
  13. package/packages/server/dist/project-types.js +35 -0
  14. package/packages/server/dist/projects.d.ts +5 -2
  15. package/packages/server/dist/projects.js +71 -17
  16. package/packages/server/dist/queued-message.d.ts +87 -0
  17. package/packages/server/dist/queued-message.js +214 -1
  18. package/packages/server/dist/routes/chats.js +27 -8
  19. package/packages/server/dist/routes/meta.js +32 -5
  20. package/packages/server/dist/routes/projects.js +71 -7
  21. package/packages/server/dist/session-hub.d.ts +69 -1
  22. package/packages/server/dist/session-hub.js +73 -1
  23. package/packages/server/dist/turn-interlock.js +8 -0
  24. package/packages/server/dist/ws-protocol.d.ts +128 -5
  25. package/packages/server/dist/ws-protocol.js +30 -13
  26. package/packages/server/dist/ws.d.ts +0 -52
  27. package/packages/server/dist/ws.js +545 -127
  28. package/packages/server/package.json +1 -1
  29. package/packages/web/dist/assets/InstanceConfigPage-DKzAHOhx.js +2 -0
  30. package/packages/web/dist/assets/{MarkdownRenderer-Bn6VPBdL.js → MarkdownRenderer-DDVO0zhl.js} +1 -1
  31. package/packages/web/dist/assets/{ProjectRedirect-CQmDBtd-.js → ProjectRedirect-WczXTV0_.js} +1 -1
  32. package/packages/web/dist/assets/ProjectView-Dd_myWpV.js +13 -0
  33. package/packages/web/dist/assets/{ProjectsGrid-BW56zqke.js → ProjectsGrid-BeJj0q6Y.js} +1 -1
  34. package/packages/web/dist/assets/{arc-CGy6WkLl.js → arc-DXqVnx_r.js} +1 -1
  35. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DJH1WIxY.js → architectureDiagram-3BPJPVTR-FgkfD1Bk.js} +1 -1
  36. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Cufb_pvX.js → blockDiagram-GPEHLZMM-WwkIIJsI.js} +1 -1
  37. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-glqeg1Ky.js → c4Diagram-AAUBKEIU-BIzkTibx.js} +1 -1
  38. package/packages/web/dist/assets/channel-DUsOl5_p.js +2 -0
  39. package/packages/web/dist/assets/{chunk-2J33WTMH-SOFTHYgh.js → chunk-2J33WTMH-C4TBH3R6.js} +1 -1
  40. package/packages/web/dist/assets/{chunk-4BX2VUAB-DFxFAiG8.js → chunk-4BX2VUAB-DTKJZlRM.js} +1 -1
  41. package/packages/web/dist/assets/{chunk-55IACEB6-CEEDxZVG.js → chunk-55IACEB6-BC9_Qc8h.js} +1 -1
  42. package/packages/web/dist/assets/{chunk-727SXJPM--3eiptsh.js → chunk-727SXJPM-D3gQ4rP3.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-AQP2D5EJ-DIHIugCo.js → chunk-AQP2D5EJ-My2AXBvz.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-FMBD7UC4-BmxWZl5c.js → chunk-FMBD7UC4-D09zE6Ow.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-ND2GUHAM-hMHtJs_I.js → chunk-ND2GUHAM-CUSgeCLl.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-QZHKN3VN-CRnnasyy.js → chunk-QZHKN3VN-B6jVPYgy.js} +1 -1
  47. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CIdDKEqH.js +2 -0
  48. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CIdDKEqH.js +2 -0
  49. package/packages/web/dist/assets/{core-CGW2CVAY.js → core-DPY0seI-.js} +1 -1
  50. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-kcl9qSfK.js → cose-bilkent-S5V4N54A-BasCiARv.js} +1 -1
  51. package/packages/web/dist/assets/{dagre-BM42HDAG-BiLsFx1W.js → dagre-BM42HDAG-Cimn11Qe.js} +1 -1
  52. package/packages/web/dist/assets/{diagram-2AECGRRQ-BcIpoY66.js → diagram-2AECGRRQ-fe9cDHyn.js} +1 -1
  53. package/packages/web/dist/assets/{diagram-5GNKFQAL-BK5p5kJa.js → diagram-5GNKFQAL-T0tQlfbl.js} +1 -1
  54. package/packages/web/dist/assets/{diagram-KO2AKTUF-BmJYalCP.js → diagram-KO2AKTUF-dLORrSiu.js} +1 -1
  55. package/packages/web/dist/assets/{diagram-LMA3HP47-BHs0SWEM.js → diagram-LMA3HP47-iUMeaR-J.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-OG6HWLK6-CSs8hie4.js → diagram-OG6HWLK6-oEH6olHK.js} +1 -1
  57. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-4qd_Td27.js → erDiagram-TEJ5UH35-VtQqF9SY.js} +1 -1
  58. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CstUgr7V.js → flowDiagram-I6XJVG4X-wvF2EkJa.js} +1 -1
  59. package/packages/web/dist/assets/{format-B_kAvAW3.js → format-CvRqOKZU.js} +1 -1
  60. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DTn9OuIc.js → ganttDiagram-6RSMTGT7-ng4qJ0E1.js} +1 -1
  61. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-E_bCor2O.js → gitGraphDiagram-PVQCEYII-BV4aWy8k.js} +1 -1
  62. package/packages/web/dist/assets/index-DGNSeqL-.js +71 -0
  63. package/packages/web/dist/assets/index-DMRbU1Jl.css +1 -0
  64. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-foRUrIOQ.js → infoDiagram-5YYISTIA-BpGTxAjU.js} +1 -1
  65. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CTON8v5t.js → ishikawaDiagram-YF4QCWOH-Cj36LyoS.js} +1 -1
  66. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-D-qwGjr7.js → journeyDiagram-JHISSGLW-0vNlg3um.js} +1 -1
  67. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-5l4y83rE.js → kanban-definition-UN3LZRKU-wijkEDaR.js} +1 -1
  68. package/packages/web/dist/assets/{linear-B1GiUWpI.js → linear-LqRymMD_.js} +1 -1
  69. package/packages/web/dist/assets/{mermaid.core-btQIOU6d.js → mermaid.core-DPrhSs95.js} +4 -4
  70. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DduczfMv.js → mindmap-definition-RKZ34NQL-en-uGqgC.js} +1 -1
  71. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D86uGusc.js → pieDiagram-4H26LBE5-DKBZ2Vnx.js} +1 -1
  72. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js → quadrantDiagram-W4KKPZXB--g-WtxyL.js} +1 -1
  73. package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
  74. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-C9aZJK93.js → requirementDiagram-4Y6WPE33-Dl4eYxDI.js} +1 -1
  75. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-b__W6SAJ.js → sankeyDiagram-5OEKKPKP-7lt4gqSw.js} +1 -1
  76. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Lr4Df8kX.js → sequenceDiagram-3UESZ5HK-COgq-dXg.js} +1 -1
  77. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-R0XxGxD5.js → stateDiagram-AJRCARHV-CL6XC79S.js} +1 -1
  78. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BDlRyphY.js +2 -0
  79. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D42Je9LW.js → timeline-definition-PNZ67QCA-C5egKeGT.js} +1 -1
  80. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-KCu14-QB.js → vennDiagram-CIIHVFJN-C_UhtC8u.js} +1 -1
  81. package/packages/web/dist/assets/{wardley-L42UT6IY-Cfir3V9o.js → wardley-L42UT6IY-Bj9chUqz.js} +1 -1
  82. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BkXjbTXu.js → wardleyDiagram-YWT4CUSO-D7VnZoRp.js} +1 -1
  83. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BVT60Pln.js → xychartDiagram-2RQKCTM6-VrWLDoi4.js} +1 -1
  84. package/packages/web/dist/index.html +2 -2
  85. package/packages/web/dist/sw.js +1 -1
  86. package/packages/web/dist/assets/InstanceConfigPage-DBrwtZjx.js +0 -2
  87. package/packages/web/dist/assets/ProjectView-BVKu9oIg.js +0 -13
  88. package/packages/web/dist/assets/channel-C4kPYn_d.js +0 -2
  89. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCg6gTVh.js +0 -2
  90. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCg6gTVh.js +0 -2
  91. package/packages/web/dist/assets/index-B-QAOq9a.js +0 -71
  92. package/packages/web/dist/assets/index-B3K5nsLe.css +0 -1
  93. package/packages/web/dist/assets/queued-LamEU0GH.js +0 -2
  94. 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
- if (resolved === root || !isPathInside(resolved, root)) {
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
- ...rest,
743
+ ...patched,
705
744
  slug: current.slug, // immutable
706
745
  started: current.started, // immutable
707
746
  updated: today(),
708
747
  };
709
- // `path` and `managed` are immutable (issue #206), enforced here rather than
710
- // by their absence from UpdateProjectInput — that type is a compile-time shape
711
- // while `rest` is spread from a request body.
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 RE-POINTED (its cwd
714
- // is baked into every transcript path, so moving it strands the history), and
715
- // one must not be given a path by a stray key, which would silently hand its
716
- // keeper a cwd somewhere else on the box that never went through
717
- // `validatePath`. `managed` is re-asserted from `current` for the same reason
718
- // — flipping it would move where the curated trio lives and either start the
719
- // sweeper writing into a checkout or orphan the existing notes. (Changing it
720
- // deliberately is a migration, not a PATCH; see #708 for what a silent
721
- // location flip does to history.)
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
- map.set(k, v);
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) — with ONE
138
- * deliberate difference: archived chats are filed away on purpose, so they
139
- * are excluded here. That means an archived chat with an unread turn still
140
- * counts toward the sidebar's badge (`buildChatTurns` never consults
141
- * `ArchiveStore`) while not appearing in this list. The two can therefore
142
- * disagree by exactly that set; making them agree means teaching the badge
143
- * about archiving, which is a behaviour change to the badge, not to this
144
- * route.
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 (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