@edspencer/paddock 0.65.0 → 0.66.1

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 (100) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/packages/server/dist/app.d.ts +8 -0
  4. package/packages/server/dist/app.js +4 -1
  5. package/packages/server/dist/cli/args.d.ts +4 -4
  6. package/packages/server/dist/cli/args.js +5 -5
  7. package/packages/server/dist/cli/paddock.js +1 -1
  8. package/packages/server/dist/config.d.ts +7 -0
  9. package/packages/server/dist/config.js +9 -1
  10. package/packages/server/dist/herdctl.d.ts +89 -0
  11. package/packages/server/dist/herdctl.js +169 -2
  12. package/packages/server/dist/instance-config.js +12 -1
  13. package/packages/server/dist/projects.d.ts +11 -0
  14. package/packages/server/dist/projects.js +40 -2
  15. package/packages/server/dist/queued-message.d.ts +57 -0
  16. package/packages/server/dist/queued-message.js +135 -1
  17. package/packages/server/dist/routes/chats.js +77 -6
  18. package/packages/server/dist/routes/projects.js +22 -2
  19. package/packages/server/dist/schema-version.d.ts +111 -0
  20. package/packages/server/dist/schema-version.js +142 -0
  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/subagents.d.ts +65 -0
  24. package/packages/server/dist/subagents.js +159 -19
  25. package/packages/server/dist/tooldetails.d.ts +10 -4
  26. package/packages/server/dist/tooldetails.js +23 -6
  27. package/packages/server/dist/trusted-proxy.js +1 -1
  28. package/packages/server/dist/turn-interlock.d.ts +101 -0
  29. package/packages/server/dist/turn-interlock.js +84 -0
  30. package/packages/server/dist/ws-protocol.d.ts +76 -5
  31. package/packages/server/dist/ws-protocol.js +2 -0
  32. package/packages/server/dist/ws.d.ts +0 -52
  33. package/packages/server/dist/ws.js +472 -119
  34. package/packages/server/package.json +1 -1
  35. package/packages/web/dist/assets/InstanceConfigPage-1BzMnJPo.js +2 -0
  36. package/packages/web/dist/assets/{MarkdownRenderer-Ri1LwAUN.js → MarkdownRenderer-CRQP3Cfe.js} +1 -1
  37. package/packages/web/dist/assets/{ProjectRedirect-Dze5H8Xy.js → ProjectRedirect-Dd3h5V8h.js} +1 -1
  38. package/packages/web/dist/assets/ProjectView-BRk6l152.js +13 -0
  39. package/packages/web/dist/assets/{ProjectsGrid-BidsM7ah.js → ProjectsGrid-BaUE8zaF.js} +1 -1
  40. package/packages/web/dist/assets/{arc-eQ3ryE9c.js → arc-DqMSW0As.js} +1 -1
  41. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Dc6Tj2Ai.js → architectureDiagram-3BPJPVTR-CNkQD1jU.js} +1 -1
  42. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DBX5pma_.js → blockDiagram-GPEHLZMM-BCMiOmt6.js} +1 -1
  43. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CnWrtTpa.js → c4Diagram-AAUBKEIU-CU2soCji.js} +1 -1
  44. package/packages/web/dist/assets/channel-Ynyq4GPh.js +2 -0
  45. package/packages/web/dist/assets/{chunk-2J33WTMH-2qmLvtsr.js → chunk-2J33WTMH-nKhlJNTt.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-4BX2VUAB-Dn-LUDMV.js → chunk-4BX2VUAB-YRTZj9zI.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-55IACEB6-DB0augUY.js → chunk-55IACEB6-HO60VE45.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-727SXJPM-BPQQM0i8.js → chunk-727SXJPM-WZJH-boe.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-AQP2D5EJ-DOhfwA-x.js → chunk-AQP2D5EJ-PvDC7gYB.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-FMBD7UC4-CatHHdFD.js → chunk-FMBD7UC4-BNRMTplH.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-ND2GUHAM-B645aAlG.js → chunk-ND2GUHAM-2Q2ei8TR.js} +1 -1
  52. package/packages/web/dist/assets/{chunk-QZHKN3VN-D1fAaPSZ.js → chunk-QZHKN3VN-CG9VV7M6.js} +1 -1
  53. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-1Pp25wwy.js +2 -0
  54. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-1Pp25wwy.js +2 -0
  55. package/packages/web/dist/assets/{core-B4t7D8Om.js → core-DwSEw_oE.js} +1 -1
  56. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BLdKDAwa.js → cose-bilkent-S5V4N54A-BlMrBI7K.js} +1 -1
  57. package/packages/web/dist/assets/{dagre-BM42HDAG-CbTbTVKv.js → dagre-BM42HDAG-tNoaleNU.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-2AECGRRQ-CorSS29_.js → diagram-2AECGRRQ-DRVCTpHL.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-5GNKFQAL-PLKivts0.js → diagram-5GNKFQAL-BdS-96WV.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-KO2AKTUF-CUSIBacZ.js → diagram-KO2AKTUF-Dv1yxz3s.js} +1 -1
  61. package/packages/web/dist/assets/{diagram-LMA3HP47-CZ69mAZs.js → diagram-LMA3HP47-BNXgifuS.js} +1 -1
  62. package/packages/web/dist/assets/{diagram-OG6HWLK6-Cmf042Rh.js → diagram-OG6HWLK6-wlO0bxwP.js} +1 -1
  63. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-DyIKGj6e.js → erDiagram-TEJ5UH35-ChoppAEq.js} +1 -1
  64. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-B79pLdLD.js → flowDiagram-I6XJVG4X-C__aYyei.js} +1 -1
  65. package/packages/web/dist/assets/{format-C5nhFL8q.js → format-u-YtEZFd.js} +1 -1
  66. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-BFTR_oNA.js → ganttDiagram-6RSMTGT7-CjRe8foW.js} +1 -1
  67. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DdGUlJxS.js → gitGraphDiagram-PVQCEYII-BGDQjiWq.js} +1 -1
  68. package/packages/web/dist/assets/index-B3K5nsLe.css +1 -0
  69. package/packages/web/dist/assets/index-Cc5bQxZk.js +71 -0
  70. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-B80FyUOy.js → infoDiagram-5YYISTIA-BP-JaX-8.js} +1 -1
  71. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-_wRlphn9.js → ishikawaDiagram-YF4QCWOH-YnsAJBvC.js} +1 -1
  72. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-qu4yvjW8.js → journeyDiagram-JHISSGLW-BM6JWTV9.js} +1 -1
  73. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-D2DEUp2-.js → kanban-definition-UN3LZRKU-gR2O0IaL.js} +1 -1
  74. package/packages/web/dist/assets/{linear-D-CLRUEW.js → linear-Dolq_eLR.js} +1 -1
  75. package/packages/web/dist/assets/{mermaid.core-DdihBJl4.js → mermaid.core-D1T_GQW7.js} +4 -4
  76. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BEh_TDBf.js → mindmap-definition-RKZ34NQL-C3bzIyLZ.js} +1 -1
  77. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BbrmLVaZ.js → pieDiagram-4H26LBE5-BrZ3ANGf.js} +1 -1
  78. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-DfVuygAv.js → quadrantDiagram-W4KKPZXB-BvLCHAEi.js} +1 -1
  79. package/packages/web/dist/assets/queued-DGmoZW59.js +2 -0
  80. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BHp2QpZt.js → requirementDiagram-4Y6WPE33-CCzXAeaZ.js} +1 -1
  81. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D2G6lRMY.js → sankeyDiagram-5OEKKPKP-2Gf-ERTg.js} +1 -1
  82. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Dj-jzR-c.js → sequenceDiagram-3UESZ5HK-DMjDe28d.js} +1 -1
  83. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-C5D6bT13.js → stateDiagram-AJRCARHV-CENtj5dw.js} +1 -1
  84. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-F7o6JfFe.js +2 -0
  85. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DE4gGjhP.js → timeline-definition-PNZ67QCA-BW4gCpBB.js} +1 -1
  86. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-C9QNrl92.js → vennDiagram-CIIHVFJN-CiIuqcT9.js} +1 -1
  87. package/packages/web/dist/assets/{wardley-L42UT6IY-U2fSGy-d.js → wardley-L42UT6IY-BQ1BPr5R.js} +1 -1
  88. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-B9RhObdH.js → wardleyDiagram-YWT4CUSO-DBq9Ailv.js} +1 -1
  89. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BnlPrtQ9.js → xychartDiagram-2RQKCTM6-D24VDECy.js} +1 -1
  90. package/packages/web/dist/index.html +2 -2
  91. package/packages/web/dist/sw.js +1 -1
  92. package/packages/web/dist/assets/InstanceConfigPage-DcprTH2W.js +0 -2
  93. package/packages/web/dist/assets/ProjectView-PrA8purE.js +0 -13
  94. package/packages/web/dist/assets/channel-B4sED4OP.js +0 -2
  95. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BlVeHFir.js +0 -2
  96. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BlVeHFir.js +0 -2
  97. package/packages/web/dist/assets/index-B6etJCHf.css +0 -1
  98. package/packages/web/dist/assets/index-DVSsta3B.js +0 -71
  99. package/packages/web/dist/assets/queued-LamEU0GH.js +0 -2
  100. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-C8NDOqTq.js +0 -2
@@ -30,6 +30,7 @@ import { sanitizeTrigger, sanitizeTriggers, isValidTriggerName, } from "./trigge
30
30
  import { sanitizeRecoveryOverride } from "./recovery-config.js";
31
31
  import { sanitizeCurationOverride } from "./curation-config.js";
32
32
  import { sanitizeAttachmentsOverride } from "./attachments-config.js";
33
+ import { PROJECT_SCHEMA_VERSION, SCHEMA_VERSION_KEY, projectSchemaSkip, } from "./schema-version.js";
33
34
  // --- re-export barrels (issue #403) ------------------------------------------
34
35
  // The moved symbols are re-exported from here so external importers keep finding
35
36
  // them at `./projects.js` — the same one-place-to-import discipline as before.
@@ -98,6 +99,8 @@ export class ProjectStore {
98
99
  this.dataDir = dataDir;
99
100
  this.log = log;
100
101
  }
102
+ /** `${file} ${declaredVersion}` pairs already warned about — see {@link warnOnceAboutSchema}. */
103
+ schemaWarned = new Set();
101
104
  /** Ensure the projects root exists. Call once at startup. */
102
105
  async init() {
103
106
  await fs.mkdir(this.root, { recursive: true });
@@ -1052,9 +1055,10 @@ export class ProjectStore {
1052
1055
  */
1053
1056
  async readSafe(key) {
1054
1057
  const dir = this.dirFor(key);
1058
+ const file = path.join(dir, PROJECT_FILE);
1055
1059
  let yaml;
1056
1060
  try {
1057
- const raw = await fs.readFile(path.join(dir, PROJECT_FILE), "utf8");
1061
+ const raw = await fs.readFile(file, "utf8");
1058
1062
  const parsed = YAML.parse(raw);
1059
1063
  if (!parsed || typeof parsed !== "object") {
1060
1064
  if (!isRootKey(key))
@@ -1062,6 +1066,18 @@ export class ProjectStore {
1062
1066
  yaml = this.normalize({}, key);
1063
1067
  }
1064
1068
  else {
1069
+ // The downgrade guard (#724), BEFORE `normalize` gets to be lenient with
1070
+ // it. A file from the future is skipped whole — including the root's,
1071
+ // which falls through to a `not_found` rather than to the empty-record
1072
+ // defaults, because those defaults are precisely what a subsequent write
1073
+ // would flatten it to. Nothing is written here, and every mutator reaches
1074
+ // the file through `get()`, so a skipped record cannot be rewritten.
1075
+ const declared = parsed[SCHEMA_VERSION_KEY];
1076
+ const skip = projectSchemaSkip(declared, file);
1077
+ if (skip !== undefined) {
1078
+ this.warnOnceAboutSchema(file, declared, skip);
1079
+ return null;
1080
+ }
1065
1081
  yaml = this.normalize(parsed, key);
1066
1082
  }
1067
1083
  }
@@ -1236,9 +1252,31 @@ export class ProjectStore {
1236
1252
  async writeYaml(slug, yaml) {
1237
1253
  const header = "# Paddock project metadata. Directory name MUST equal `slug`.\n" +
1238
1254
  "# status: idea | active | paused | blocked | done | abandoned\n";
1239
- const body = YAML.stringify(yaml);
1255
+ // Stamped here rather than threaded through ProjectYaml/the DTO, because
1256
+ // this is the one write choke point and because what it describes is the
1257
+ // FILE, not the project (#724). Unconditional and first: every write is a
1258
+ // full rewrite from the normalized record, so what lands on disk is by
1259
+ // construction this build's shape whatever the file said before. A legacy
1260
+ // file therefore gains the key the next time it is saved for some other
1261
+ // reason — there is no backfill pass, and merely reading one writes nothing.
1262
+ const body = YAML.stringify({ [SCHEMA_VERSION_KEY]: PROJECT_SCHEMA_VERSION, ...yaml });
1240
1263
  await fs.writeFile(path.join(this.dirFor(slug), PROJECT_FILE), header + body, "utf8");
1241
1264
  }
1265
+ /**
1266
+ * Say out loud that a project was skipped for being from the future (#724),
1267
+ * once per (file, declared version).
1268
+ *
1269
+ * `list()` runs on every `GET /api/projects`, so an unconditional warn would
1270
+ * bury the log. Keyed on the declared version as well as the path so a
1271
+ * hand-edit — or the same instance being downgraded further — speaks up again.
1272
+ */
1273
+ warnOnceAboutSchema(file, declared, message) {
1274
+ const seen = `${file} @ ${JSON.stringify(declared) ?? "?"}`;
1275
+ if (this.schemaWarned.has(seen))
1276
+ return;
1277
+ this.schemaWarned.add(seen);
1278
+ this.log.warn(message);
1279
+ }
1242
1280
  toDto(dir, yaml, hasOverview) {
1243
1281
  return {
1244
1282
  ...yaml,
@@ -1,8 +1,38 @@
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
+ }
1
15
  /** A queued message awaiting auto-send. */
2
16
  export interface QueuedMessage {
17
+ /** The slot's full text — what gets sent. */
3
18
  text: string;
19
+ /** SERVER-stamped enqueue time. Informational — never load-bearing (#736). */
4
20
  createdAtMs: number;
21
+ /**
22
+ * The slot's current VERSION, minted on every write and broadcast to every
23
+ * attached client. A client echoes back the version it last saw, which is how
24
+ * the server can tell "this client is editing the slot it can see" from "this
25
+ * client composed a queue of its own, not knowing about the other one" (#629).
26
+ */
27
+ id?: string;
28
+ /**
29
+ * Every (id, text) this slot has been known by — the dedup ledger. Absent on an
30
+ * entry written by an older server; readers treat that as one legacy identity.
31
+ */
32
+ parts?: QueuedPart[];
5
33
  }
34
+ /** The identities of an entry, synthesising one for a legacy entry without any. */
35
+ export declare function partsOf(m: QueuedMessage): QueuedPart[];
6
36
  export declare class QueuedMessageStore {
7
37
  private readonly stateFile;
8
38
  /** In-memory map of key -> QueuedMessage (loaded once, written through on change). */
@@ -30,6 +60,33 @@ export declare class QueuedMessageStore {
30
60
  * Setting `null` deletes the key, keeping the file compact.
31
61
  */
32
62
  set(agent: string, sessionId: string, message: QueuedMessage | null): Promise<void>;
63
+ /**
64
+ * Write one client's queue into a chat's single slot, MERGING rather than
65
+ * overwriting (#629), and return the resulting entry. The read-modify-write
66
+ * happens with no `await` between the load and the mutation, so two clients
67
+ * queueing at once can't lose one another's text the way the old unconditional
68
+ * `set` overwrite did.
69
+ *
70
+ * `incoming.id` is the identity the client is writing under — either the slot
71
+ * VERSION it last saw (broadcast to it via `chat:queued_state`) or, if it has
72
+ * never seen one, its own queue id. That is what distinguishes the two cases:
73
+ *
74
+ * - **it matches the current version** ⇒ the client can see the slot as it is
75
+ * now, so its text IS the whole slot: replace. This covers the ordinary
76
+ * single-client edit/append (#245 stable identity) and any client that has
77
+ * caught up with a merge.
78
+ * - **it doesn't** ⇒ the client composed this queue without knowing the
79
+ * slot's current contents (a second device, or a tab that missed the
80
+ * broadcast): APPEND, so neither message is lost.
81
+ *
82
+ * A write whose text the slot has already been known by is a re-assert — a
83
+ * reconnecting client pushing its stored copy — and is a no-op, so reconnecting
84
+ * can't duplicate text into the slot.
85
+ *
86
+ * `nowMs` stamps a fresh slot's `createdAtMs` SERVER-side; an existing slot keeps
87
+ * the one it has. The client's clock never enters the store (#736).
88
+ */
89
+ upsert(agent: string, sessionId: string, incoming: QueuedPart, nowMs: number, mintVersion: () => string): Promise<QueuedMessage | null>;
33
90
  /** Write-through, serialised so overlapping writes can't corrupt the file. */
34
91
  private persist;
35
92
  }
@@ -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,45 @@ 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
+ * Fold a write from a client that ISN'T on the slot's current version into the
54
+ * slot's text.
55
+ *
56
+ * Usually that client is simply appending to its own queue: it holds text the slot
57
+ * already contains and has added a line to the end of it. That is the ordinary
58
+ * single-client append — the composer keeps one queue id across an append (#245),
59
+ * and an older client sends only its enqueue `ts`, so neither ever carries the
60
+ * current version. It also covers the plain race of appending faster than the
61
+ * broadcast round trip. In every one of those, the client's text EXTENDS something
62
+ * the slot already holds, so we substitute in place rather than appending — or the
63
+ * client's own earlier text would appear twice.
64
+ *
65
+ * Otherwise the client queued without knowing what was already there (a second
66
+ * device), and its text is appended so neither message is lost (#629).
67
+ */
68
+ function mergeInto(current, known, incoming) {
69
+ // The most specific thing the incoming text extends — longest first, since a
70
+ // queue that has been appended to is known by every prefix it has passed through.
71
+ const extended = known
72
+ .filter((p) => p.text.length > 0 && incoming.startsWith(`${p.text}${MERGE_SEP}`))
73
+ .sort((a, b) => b.text.length - a.text.length)[0];
74
+ const at = extended ? current.indexOf(extended.text) : -1;
75
+ if (at < 0)
76
+ return `${current}${MERGE_SEP}${incoming}`;
77
+ // Splice rather than String.replace: the texts are user prose and `$&`-style
78
+ // patterns in a replacement string would be interpreted.
79
+ return current.slice(0, at) + incoming + current.slice(at + extended.text.length);
80
+ }
81
+ /** The identities of an entry, synthesising one for a legacy entry without any. */
82
+ export function partsOf(m) {
83
+ if (m.parts && m.parts.length > 0)
84
+ return m.parts;
85
+ return [{ id: m.id ?? `legacy:${m.createdAtMs}`, text: m.text }];
86
+ }
28
87
  export class QueuedMessageStore {
29
88
  stateFile;
30
89
  /** In-memory map of key -> QueuedMessage (loaded once, written through on change). */
@@ -49,7 +108,24 @@ export class QueuedMessageStore {
49
108
  typeof v === "object" &&
50
109
  typeof v.text === "string" &&
51
110
  typeof v.createdAtMs === "number") {
52
- map.set(k, v);
111
+ const entry = v;
112
+ // `parts` is optional (an older server wrote entries without it) and
113
+ // corruption-tolerant like everything else here: a malformed parts
114
+ // array is dropped, leaving a legacy single-part entry, not a throw.
115
+ const raw = v.parts;
116
+ const parts = Array.isArray(raw)
117
+ ? raw.filter((p) => Boolean(p) &&
118
+ typeof p === "object" &&
119
+ typeof p.id === "string" &&
120
+ typeof p.text === "string")
121
+ : [];
122
+ const id = typeof v.id === "string" ? entry.id : undefined;
123
+ map.set(k, {
124
+ text: entry.text,
125
+ createdAtMs: entry.createdAtMs,
126
+ ...(id ? { id } : {}),
127
+ ...(parts.length > 0 ? { parts } : {}),
128
+ });
53
129
  }
54
130
  }
55
131
  }
@@ -102,6 +178,64 @@ export class QueuedMessageStore {
102
178
  }
103
179
  await this.persist(map);
104
180
  }
181
+ /**
182
+ * Write one client's queue into a chat's single slot, MERGING rather than
183
+ * overwriting (#629), and return the resulting entry. The read-modify-write
184
+ * happens with no `await` between the load and the mutation, so two clients
185
+ * queueing at once can't lose one another's text the way the old unconditional
186
+ * `set` overwrite did.
187
+ *
188
+ * `incoming.id` is the identity the client is writing under — either the slot
189
+ * VERSION it last saw (broadcast to it via `chat:queued_state`) or, if it has
190
+ * never seen one, its own queue id. That is what distinguishes the two cases:
191
+ *
192
+ * - **it matches the current version** ⇒ the client can see the slot as it is
193
+ * now, so its text IS the whole slot: replace. This covers the ordinary
194
+ * single-client edit/append (#245 stable identity) and any client that has
195
+ * caught up with a merge.
196
+ * - **it doesn't** ⇒ the client composed this queue without knowing the
197
+ * slot's current contents (a second device, or a tab that missed the
198
+ * broadcast): APPEND, so neither message is lost.
199
+ *
200
+ * A write whose text the slot has already been known by is a re-assert — a
201
+ * reconnecting client pushing its stored copy — and is a no-op, so reconnecting
202
+ * can't duplicate text into the slot.
203
+ *
204
+ * `nowMs` stamps a fresh slot's `createdAtMs` SERVER-side; an existing slot keeps
205
+ * the one it has. The client's clock never enters the store (#736).
206
+ */
207
+ async upsert(agent, sessionId, incoming, nowMs, mintVersion) {
208
+ const map = await this.ensureLoaded();
209
+ const key = keyOf(agent, sessionId);
210
+ const current = map.get(key) ?? null;
211
+ if (!incoming.text.trim())
212
+ return current;
213
+ const known = current ? partsOf(current) : [];
214
+ // Already folded in under some identity ⇒ a re-assert, not new text.
215
+ if (known.some((p) => p.text === incoming.text))
216
+ return current;
217
+ const text = !current
218
+ ? incoming.text
219
+ : incoming.id === current.id
220
+ ? // The client can see the slot as it stands, so its text IS the slot.
221
+ incoming.text
222
+ : mergeInto(current.text, known, incoming.text);
223
+ const version = mintVersion();
224
+ // Remember BOTH identities this write creates: what the client sent (which it
225
+ // will re-assert on reload) and the version we hand back (which it will
226
+ // re-assert once it adopts the broadcast). Either one reaching a later drain
227
+ // has to be recognisable as already-flushed.
228
+ const parts = [...known, { id: incoming.id, text: incoming.text }, { id: version, text }].slice(-MAX_KNOWN);
229
+ const next = {
230
+ text,
231
+ createdAtMs: current?.createdAtMs ?? nowMs,
232
+ id: version,
233
+ parts,
234
+ };
235
+ map.set(key, next);
236
+ await this.persist(map);
237
+ return next;
238
+ }
105
239
  /** Write-through, serialised so overlapping writes can't corrupt the file. */
106
240
  persist(map) {
107
241
  this.writing = this.writing.then(async () => {
@@ -24,6 +24,7 @@ import { childOf, HUMAN_ROOT, ADOPTED_ROOT } from "../run-provenance.js";
24
24
  import { sendProjectError } from "../route-errors.js";
25
25
  import { mapWithConcurrency, FILE_FANOUT_CONCURRENCY } from "../concurrency.js";
26
26
  import { CHAT_USAGE_SCOPES, SAFE_SESSION_ID, RUNS_SEEN_SESSION, clampRunsLimit, toChatUsage, buildProjectChats, makeTriggerResolver, makeParentResolver, normalizeBatchSessionIds, BATCH_SESSIONS_MAX, } from "../chat-dto.js";
27
+ import { quiesceSession, turnRunningError } from "../turn-interlock.js";
27
28
  /**
28
29
  * Workspace-scoped chat routes: paths are declared RELATIVE to the workspace
29
30
  * (e.g. `/chats/:sessionId`), and this plugin is mounted TWICE — once at
@@ -56,6 +57,17 @@ export function registerChatWorkspaceRoutes(app, ctx) {
56
57
  * feed then degrades to its unread half instead of 500ing.
57
58
  */
58
59
  const isRunning = (sessionId) => ctx.managementOpsContext?.hub.isRunning(sessionId) ?? false;
60
+ /**
61
+ * The interlock every DESTRUCTIVE chat route runs first (issue #731).
62
+ *
63
+ * `claude` writes the transcript itself, so unlinking or rewriting it while a
64
+ * turn is live does not remove the chat — the surviving process re-creates the
65
+ * file from its own tail and the chat comes back with its history gone. These
66
+ * routes therefore stop the turn and WAIT for it to be dead before touching a
67
+ * byte; `stuck` means we could not confirm that, and the only safe answer then
68
+ * is to refuse.
69
+ */
70
+ const interlock = { hub: ctx.managementOpsContext?.hub, herdctl };
59
71
  // --- chats (sessions) --------------------------------------------------
60
72
  app.get("/chats", {
61
73
  schema: {
@@ -595,7 +607,12 @@ export function registerChatWorkspaceRoutes(app, ctx) {
595
607
  },
596
608
  response: {
597
609
  200: {
598
- description: "Object `{ ok, removed }` indicating whether the transcript was removed.",
610
+ description: "Object `{ ok, removed, retained, cancelledTurn }` — whether the transcript was removed, and whether a live turn had to be stopped first (#731).",
611
+ type: "object",
612
+ additionalProperties: true,
613
+ },
614
+ 409: {
615
+ description: "`{ code: 'turn_running' }` — a turn is still running on this chat and could not be stopped, so nothing was deleted (#731).",
599
616
  type: "object",
600
617
  additionalProperties: true,
601
618
  },
@@ -604,6 +621,15 @@ export function registerChatWorkspaceRoutes(app, ctx) {
604
621
  }, async (req, reply) => {
605
622
  try {
606
623
  const agent = await agentForSlug(req.params.slug);
624
+ // #731: stop any in-flight turn FIRST. A delete is an unambiguous "this
625
+ // chat is going away", so we cancel rather than refuse — refusing would
626
+ // strand the user behind a turn that may never end (a hung turn was
627
+ // exactly how this was found). If the turn will not die we refuse, since
628
+ // deleting under a live writer is what resurrected the chat.
629
+ const quiesced = await quiesceSession(interlock, req.params.sessionId);
630
+ if (quiesced === "stuck") {
631
+ return reply.code(409).send(turnRunningError([req.params.sessionId]));
632
+ }
607
633
  await cleanupAttachments(agent, req.params.sessionId);
608
634
  const { removed, retained } = await herdctl.deleteSession(agent, req.params.sessionId);
609
635
  // Drop any archived/starred/unread/detached flag so a future session id
@@ -619,7 +645,9 @@ export function registerChatWorkspaceRoutes(app, ctx) {
619
645
  // has to be cleared on the same path — otherwise a recycled session id
620
646
  // silently starts life detached from a parent it never had.
621
647
  await parentDetach.setDetached(agent, req.params.sessionId, false).catch(() => undefined);
622
- return reply.code(200).send({ ok: true, removed, retained });
648
+ return reply
649
+ .code(200)
650
+ .send({ ok: true, removed, retained, cancelledTurn: quiesced === "stopped" });
623
651
  }
624
652
  catch (err) {
625
653
  return sendProjectError(reply, err);
@@ -751,7 +779,12 @@ export function registerChatWorkspaceRoutes(app, ctx) {
751
779
  },
752
780
  response: {
753
781
  200: {
754
- description: "Object reporting how many messages were removed.",
782
+ description: "Object reporting how many messages were removed, and whether a live turn had to be stopped first (`cancelledTurn`, #731).",
783
+ type: "object",
784
+ additionalProperties: true,
785
+ },
786
+ 409: {
787
+ description: "`{ code: 'turn_running' }` — a turn is still running on this chat and could not be stopped, so nothing was truncated (#731).",
755
788
  type: "object",
756
789
  additionalProperties: true,
757
790
  },
@@ -763,8 +796,19 @@ export function registerChatWorkspaceRoutes(app, ctx) {
763
796
  if (!uuid)
764
797
  return reply.code(400).send({ error: "uuid is required" });
765
798
  const project = await projects.get(req.params.slug);
799
+ // #731: revert truncates the LIVE transcript in place — the same file a
800
+ // running turn is appending to — so mid-turn it left an orphan
801
+ // `tool_result` whose `tool_use` had just been truncated away. Cancel
802
+ // rather than refuse: an in-flight turn is by definition part of the tail
803
+ // the user just asked to throw away.
804
+ const quiesced = await quiesceSession(interlock, req.params.sessionId);
805
+ if (quiesced === "stuck") {
806
+ return reply.code(409).send(turnRunningError([req.params.sessionId]));
807
+ }
766
808
  const { removed } = await herdctl.revertSession(project, req.params.sessionId, uuid);
767
- return reply.code(200).send({ ok: true, removed });
809
+ return reply
810
+ .code(200)
811
+ .send({ ok: true, removed, cancelledTurn: quiesced === "stopped" });
768
812
  }
769
813
  catch (err) {
770
814
  return sendProjectError(reply, err);
@@ -1206,6 +1250,14 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1206
1250
  const failed = [];
1207
1251
  for (const sessionId of sessionIds) {
1208
1252
  try {
1253
+ // #731: same interlock as the single delete, per id. A chat whose
1254
+ // turn refuses to die lands in `failed` rather than aborting the
1255
+ // batch — the route is already best-effort-and-honest per chat, and
1256
+ // "still running" is exactly the kind of partial failure it reports.
1257
+ if ((await quiesceSession(interlock, sessionId)) === "stuck") {
1258
+ failed.push(sessionId);
1259
+ continue;
1260
+ }
1209
1261
  await cleanupAttachments(agent, sessionId);
1210
1262
  const gone = await herdctl.deleteSession(agent, sessionId);
1211
1263
  // `retained` counts as success here, and the distinction matters for
@@ -1279,7 +1331,12 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1279
1331
  },
1280
1332
  response: {
1281
1333
  201: {
1282
- description: "Object `{ project, promoted, sessionId }` describing the created project and whether the transcript was re-homed.",
1334
+ description: "Object `{ project, promoted, sessionId, cancelledTurn }` describing the created project, whether the transcript was re-homed, and whether a live turn had to be stopped first (#731).",
1335
+ type: "object",
1336
+ additionalProperties: true,
1337
+ },
1338
+ 409: {
1339
+ description: "`{ code: 'turn_running' }` — a turn is still running on this chat and could not be stopped. Nothing was moved and NO project was created (#731).",
1283
1340
  type: "object",
1284
1341
  additionalProperties: true,
1285
1342
  },
@@ -1292,6 +1349,15 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1292
1349
  }
1293
1350
  try {
1294
1351
  const from = await projects.get(req.params.slug);
1352
+ // #731: promote MOVES the transcript, so mid-turn it lost the chat from
1353
+ // BOTH projects — the surviving process rewrote the source path it still
1354
+ // held, while the moved copy went unlisted in the target with no recovery
1355
+ // path in the UI. Quiesce BEFORE `projects.create` so a refusal doesn't
1356
+ // leave an empty project behind as a side-effect of a failed promote.
1357
+ const quiesced = await quiesceSession(interlock, req.params.sessionId);
1358
+ if (quiesced === "stuck") {
1359
+ return reply.code(409).send(turnRunningError([req.params.sessionId]));
1360
+ }
1295
1361
  const project = await projects.create({
1296
1362
  name: body.name,
1297
1363
  slug: body.slug,
@@ -1315,7 +1381,12 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1315
1381
  catch (err) {
1316
1382
  req.log.warn({ err }, "promote: could not re-home the transcript");
1317
1383
  }
1318
- return reply.code(201).send({ project, promoted, sessionId: req.params.sessionId });
1384
+ return reply.code(201).send({
1385
+ project,
1386
+ promoted,
1387
+ sessionId: req.params.sessionId,
1388
+ cancelledTurn: quiesced === "stopped",
1389
+ });
1319
1390
  }
1320
1391
  catch (err) {
1321
1392
  return sendProjectError(reply, err);
@@ -1,9 +1,10 @@
1
- import { ProjectError, ROOT_KEY, } from "../projects.js";
1
+ import { ProjectError, ROOT_KEY, isRootKey, } from "../projects.js";
2
2
  import { keeperAgentName } from "../herdctl.js";
3
3
  import { isKnownModel, isKnownPermissionMode, isValidMaxTurns, isKnownDriveMode, resolveModels, MAX_TURNS_LIMIT, } from "../models.js";
4
4
  import { isValidMaxSpawnDepth, MAX_SPAWN_DEPTH_LIMIT } from "../spawn-capability.js";
5
5
  import { sendProjectError } from "../route-errors.js";
6
6
  import { buildProjectChats, makeTriggerResolver, makeParentResolver } from "../chat-dto.js";
7
+ import { quiesceProject, turnRunningError } from "../turn-interlock.js";
7
8
  /**
8
9
  * Build ONE workspace's compact `{ sessionId, lastTurnCompletedAt, lastSeen?,
9
10
  * unread? }` list — the sidebar's UNREAD-badge feed (#161), server-backed (#189).
@@ -406,11 +407,30 @@ export function registerProjectWorkspaceRoutes(app, ctx) {
406
407
  type: "object",
407
408
  additionalProperties: true,
408
409
  },
410
+ 409: {
411
+ description: "`{ code: 'turn_running', sessionIds }` — one or more of the project's chats has a turn that could not be stopped, so nothing was deleted (#731).",
412
+ type: "object",
413
+ additionalProperties: true,
414
+ },
409
415
  },
410
416
  },
411
417
  }, async (req, reply) => {
412
418
  try {
413
- const project = await projects.remove(req.params.slug); // throws not_found
419
+ // #731: a project delete takes every chat in it, so it races the same live
420
+ // writers a chat delete does — and the orphaned `claude` was observed
421
+ // running on with a cwd of `…/<slug> (deleted)`. Stop them all first, and
422
+ // refuse rather than remove the directory out from under one.
423
+ //
424
+ // `remove()` refuses the ROOT workspace, and a refusal must not have killed
425
+ // the root's live turns on the way to saying no — so that one check comes
426
+ // first, mirroring the guard in `remove()` rather than duplicating its
427
+ // error.
428
+ if (!isRootKey(req.params.slug)) {
429
+ const stuck = await quiesceProject({ hub: ctx.managementOpsContext?.hub, herdctl }, req.params.slug);
430
+ if (stuck.length > 0)
431
+ return reply.code(409).send(turnRunningError(stuck));
432
+ }
433
+ const project = await projects.remove(req.params.slug); // throws not_found / refuses root
414
434
  try {
415
435
  await herdctl.removeProjectAgent(project.slug, Object.keys(project.hooks ?? {}), Object.keys(project.triggers ?? {}));
416
436
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Schema versioning for the two on-disk formats paddock OWNS (issue #724).
3
+ *
4
+ * Those two are `project.yaml` and `paddock.config.yaml`, and only those two.
5
+ * The `version: 1` fields elsewhere in the tree belong to herdctl — its fleet
6
+ * config (`herdctl-agent-config.ts`) and `@herdctl/core`'s session metadata —
7
+ * and transcripts are Claude Code's format. Paddock reads neither back.
8
+ *
9
+ * ## What this is for: the DOWNGRADE guard, not migrations
10
+ *
11
+ * There is no migration runner here, deliberately (#724, and #717 for the
12
+ * sequencing). With `1` as the only version there is nothing to migrate, and a
13
+ * runner with zero migrations cannot be meaningfully tested.
14
+ *
15
+ * The value delivered on its own is the guard. Running an OLDER paddock against
16
+ * a data dir a newer one wrote — `npx @edspencer/paddock@0.62.0` is one command
17
+ * away — lenient-parses it: `ProjectStore.normalize` drops every key it does not
18
+ * recognise, and the next `update()` writes the file back without them. A
19
+ * `path:` or a `managed:` disappears with no error. A declared version turns
20
+ * that into "this file is version 4, I understand 1, I am not touching it".
21
+ *
22
+ * **Never lenient-parse a file from the future.** That is the whole point.
23
+ *
24
+ * ## Adoption cost: nothing
25
+ *
26
+ * The CURRENT on-disk shape *is* version 1, and an ABSENT `schemaVersion` reads
27
+ * as 1 — so no live instance needs rewriting and nothing pre-adoption can break,
28
+ * because those files genuinely are v1. Files we write carry the field
29
+ * explicitly; existing files gain it whenever they are next written for some
30
+ * other reason. There is no backfill.
31
+ *
32
+ * ## When to bump — read this before changing either format
33
+ *
34
+ * Bump ONLY when an old reader would get the wrong answer, or a new reader
35
+ * cannot recover the truth from an old file:
36
+ *
37
+ * | Change | Bump? |
38
+ * | --------------------------------------------------- | ----- |
39
+ * | Add an optional key with a safe default | **No** — lenient parsing already handles it |
40
+ * | Rename a key | Yes |
41
+ * | Change what an existing key *means* | Yes — silent misinterpretation, the dangerous one |
42
+ * | Change a default so *absence* now means something new | Yes |
43
+ * | Remove a load-bearing key | Yes |
44
+ *
45
+ * Adding a key does not bump, which covers most changes. The additive-vs-
46
+ * breaking distinction is expressed by NOT bumping, rather than by a second
47
+ * version component — hence a monotonic integer and never semver. A data file
48
+ * has one consumer, the app, and it always wants latest: there is no version
49
+ * negotiation for a minor/patch to describe, and migrations chain (1→2→3), which
50
+ * "does 1.2→2.0 include 1.3?" cannot express. Rails, Django, SQLite's
51
+ * `user_version` and IndexedDB all land in the same place.
52
+ *
53
+ * Worked example, so the table is not abstract. v0.64.0's `managed` default is
54
+ * `managed ?? !(repo || path)` (`project-paths.ts`) — row four, the absence of a
55
+ * key acquiring a derived meaning. Under this scheme that was a 1→2 migration:
56
+ * read v1, compute `managed`, write it explicitly, stamp 2. Instead it is a
57
+ * derivation re-evaluated on every read, forever, with `stripDto` deliberately
58
+ * letting `managed` survive so the first PATCH pins it. It works, but it is a
59
+ * migration in disguise, and that pattern accretes.
60
+ *
61
+ * The two constants are independent on purpose: a breaking change to
62
+ * `project.yaml` should not force every `paddock.config.yaml` to claim a version
63
+ * whose shape it never had. They happen to both be 1 today.
64
+ */
65
+ /** Schema version of the `project.yaml` format this build writes and understands. */
66
+ export declare const PROJECT_SCHEMA_VERSION = 1;
67
+ /** Schema version of the `paddock.config.yaml` format this build writes and understands. */
68
+ export declare const CONFIG_SCHEMA_VERSION = 1;
69
+ /** The key both formats carry it under. */
70
+ export declare const SCHEMA_VERSION_KEY = "schemaVersion";
71
+ /**
72
+ * The version a parsed file declares, or `null` if it declares something we
73
+ * cannot make sense of.
74
+ *
75
+ * - **Absent / null** → {@link PROJECT_SCHEMA_VERSION}-era, i.e. `1`. Every file
76
+ * written before adoption is genuinely a v1 file.
77
+ * - A positive integer (or a string spelling one, since YAML quoting is easy to
78
+ * get wrong by hand) → that integer.
79
+ * - Anything else — `0`, `-1`, `1.5`, `"banana"`, a list — → `null`.
80
+ *
81
+ * `null` is deliberately treated by callers the same way a from-the-future
82
+ * version is, not the same way an absent one is. A file whose own claim about
83
+ * its shape is unreadable is a file we cannot establish is safe to rewrite, and
84
+ * the failure mode this whole mechanism exists to prevent is rewriting one we
85
+ * did not understand.
86
+ */
87
+ export declare function readSchemaVersion(raw: unknown): number | null;
88
+ /** Whether `raw` declares a version this build cannot safely read. */
89
+ export declare function isFromTheFuture(raw: unknown, supported: number): boolean;
90
+ /**
91
+ * Why `paddock.config.yaml` is unreadable by this build, or `undefined` if it is
92
+ * fine. Pure, so the decision is testable without a filesystem.
93
+ *
94
+ * Fail-closed, in the same shape as the `claudeHome`-points-at-your-own-`~/.claude`
95
+ * refusal in `config.ts`: an instance config from the future governs auth mode,
96
+ * bind host and data paths, and half-understanding those is worse than not
97
+ * starting.
98
+ */
99
+ export declare function configSchemaRefusal(raw: unknown, configPath: string): string | undefined;
100
+ /**
101
+ * Why a `project.yaml` cannot be read by this build, or `undefined` if it is fine.
102
+ *
103
+ * Asymmetric with {@link configSchemaRefusal} on purpose, and this is the
104
+ * judgement call worth knowing about: a project from the future is SKIPPED,
105
+ * loudly, rather than refusing the boot. `readSafe` already returns `null` for
106
+ * an unreadable `project.yaml` and the project simply vanishes from `list()`, so
107
+ * saying it out loud is a strict improvement on today. Bricking a whole instance
108
+ * because one project directory was copied in from a newer box would be a worse
109
+ * failure than the data loss being prevented.
110
+ */
111
+ export declare function projectSchemaSkip(raw: unknown, file: string): string | undefined;