@edspencer/paddock 0.66.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.
- 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/queued-message.d.ts +57 -0
- package/packages/server/dist/queued-message.js +135 -1
- 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 +76 -5
- package/packages/server/dist/ws-protocol.js +2 -0
- package/packages/server/dist/ws.d.ts +0 -52
- package/packages/server/dist/ws.js +472 -119
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-DBrwtZjx.js → InstanceConfigPage-1BzMnJPo.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-Bn6VPBdL.js → MarkdownRenderer-CRQP3Cfe.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-CQmDBtd-.js → ProjectRedirect-Dd3h5V8h.js} +1 -1
- package/packages/web/dist/assets/ProjectView-BRk6l152.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-BW56zqke.js → ProjectsGrid-BaUE8zaF.js} +1 -1
- package/packages/web/dist/assets/{arc-CGy6WkLl.js → arc-DqMSW0As.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DJH1WIxY.js → architectureDiagram-3BPJPVTR-CNkQD1jU.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Cufb_pvX.js → blockDiagram-GPEHLZMM-BCMiOmt6.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-glqeg1Ky.js → c4Diagram-AAUBKEIU-CU2soCji.js} +1 -1
- package/packages/web/dist/assets/channel-Ynyq4GPh.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-SOFTHYgh.js → chunk-2J33WTMH-nKhlJNTt.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DFxFAiG8.js → chunk-4BX2VUAB-YRTZj9zI.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-CEEDxZVG.js → chunk-55IACEB6-HO60VE45.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM--3eiptsh.js → chunk-727SXJPM-WZJH-boe.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DIHIugCo.js → chunk-AQP2D5EJ-PvDC7gYB.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BmxWZl5c.js → chunk-FMBD7UC4-BNRMTplH.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-hMHtJs_I.js → chunk-ND2GUHAM-2Q2ei8TR.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CRnnasyy.js → chunk-QZHKN3VN-CG9VV7M6.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-1Pp25wwy.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-1Pp25wwy.js +2 -0
- package/packages/web/dist/assets/{core-CGW2CVAY.js → core-DwSEw_oE.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-kcl9qSfK.js → cose-bilkent-S5V4N54A-BlMrBI7K.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-BiLsFx1W.js → dagre-BM42HDAG-tNoaleNU.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-BcIpoY66.js → diagram-2AECGRRQ-DRVCTpHL.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-BK5p5kJa.js → diagram-5GNKFQAL-BdS-96WV.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-BmJYalCP.js → diagram-KO2AKTUF-Dv1yxz3s.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BHs0SWEM.js → diagram-LMA3HP47-BNXgifuS.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-CSs8hie4.js → diagram-OG6HWLK6-wlO0bxwP.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-4qd_Td27.js → erDiagram-TEJ5UH35-ChoppAEq.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CstUgr7V.js → flowDiagram-I6XJVG4X-C__aYyei.js} +1 -1
- package/packages/web/dist/assets/{format-B_kAvAW3.js → format-u-YtEZFd.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DTn9OuIc.js → ganttDiagram-6RSMTGT7-CjRe8foW.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-E_bCor2O.js → gitGraphDiagram-PVQCEYII-BGDQjiWq.js} +1 -1
- package/packages/web/dist/assets/index-Cc5bQxZk.js +71 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-foRUrIOQ.js → infoDiagram-5YYISTIA-BP-JaX-8.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CTON8v5t.js → ishikawaDiagram-YF4QCWOH-YnsAJBvC.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-D-qwGjr7.js → journeyDiagram-JHISSGLW-BM6JWTV9.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-5l4y83rE.js → kanban-definition-UN3LZRKU-gR2O0IaL.js} +1 -1
- package/packages/web/dist/assets/{linear-B1GiUWpI.js → linear-Dolq_eLR.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-btQIOU6d.js → mermaid.core-D1T_GQW7.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DduczfMv.js → mindmap-definition-RKZ34NQL-C3bzIyLZ.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D86uGusc.js → pieDiagram-4H26LBE5-BrZ3ANGf.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js → quadrantDiagram-W4KKPZXB-BvLCHAEi.js} +1 -1
- package/packages/web/dist/assets/queued-DGmoZW59.js +2 -0
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-C9aZJK93.js → requirementDiagram-4Y6WPE33-CCzXAeaZ.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-b__W6SAJ.js → sankeyDiagram-5OEKKPKP-2Gf-ERTg.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Lr4Df8kX.js → sequenceDiagram-3UESZ5HK-DMjDe28d.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-R0XxGxD5.js → stateDiagram-AJRCARHV-CENtj5dw.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-F7o6JfFe.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D42Je9LW.js → timeline-definition-PNZ67QCA-BW4gCpBB.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-KCu14-QB.js → vennDiagram-CIIHVFJN-CiIuqcT9.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-Cfir3V9o.js → wardley-L42UT6IY-BQ1BPr5R.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BkXjbTXu.js → wardleyDiagram-YWT4CUSO-DBq9Ailv.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BVT60Pln.js → xychartDiagram-2RQKCTM6-D24VDECy.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- 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/queued-LamEU0GH.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-i2pZN-iR.js +0 -2
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@ import { HerdctlService } from "./herdctl.js";
|
|
|
16
16
|
import { GitService } from "./git.js";
|
|
17
17
|
import { GithubAuth } from "./github-auth.js";
|
|
18
18
|
import { type Transcriber } from "./transcribe.js";
|
|
19
|
+
import type { SessionHub } from "./session-hub.js";
|
|
19
20
|
import { SweepService } from "./sweep.js";
|
|
20
21
|
import { ArchiveStore } from "./archive.js";
|
|
21
22
|
import { StarStore } from "./star.js";
|
|
@@ -49,6 +50,13 @@ export interface BuiltApp {
|
|
|
49
50
|
events: PaddockEventBus;
|
|
50
51
|
/** Unified trigger registry (Epic T / T1) — the sole trigger CRUD surface. */
|
|
51
52
|
triggers: TriggerService;
|
|
53
|
+
/**
|
|
54
|
+
* The WS layer's session hub — every in-flight turn, and the fan-out to the
|
|
55
|
+
* sockets watching it. Exposed for callers that need to observe or annotate a
|
|
56
|
+
* turn without holding a socket (the destructive-op interlock reaches it the
|
|
57
|
+
* same way, via the route context).
|
|
58
|
+
*/
|
|
59
|
+
hub: SessionHub;
|
|
52
60
|
/** Tear down the fleet + close the server (no process.exit, for tests). */
|
|
53
61
|
close: () => Promise<void>;
|
|
54
62
|
}
|
|
@@ -399,5 +399,8 @@ export async function buildApp(opts = {}) {
|
|
|
399
399
|
await herdctl.stop().catch(() => undefined);
|
|
400
400
|
await app.close().catch(() => undefined);
|
|
401
401
|
};
|
|
402
|
-
|
|
402
|
+
// `hub` rides along like every other built piece: it is the WS layer's turn
|
|
403
|
+
// bookkeeping, and the only place a caller can observe or annotate a turn
|
|
404
|
+
// without a live socket (see SessionHub.noteCancel).
|
|
405
|
+
return { app, cfg, projects, herdctl, git, githubAuth, sweep, archive, star, readState, unread, parentDetach, runProvenance, queuedMessage, transcriber, events, triggers, hub: chatHandler.managementOpsContext.hub, close };
|
|
403
406
|
}
|
|
@@ -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
|
-
|
|
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 () => {
|
|
@@ -98,6 +98,17 @@ export interface ActiveInfo {
|
|
|
98
98
|
jobId: string | null;
|
|
99
99
|
running: boolean;
|
|
100
100
|
}
|
|
101
|
+
/** Why a turn is being cancelled, and which socket asked for it. */
|
|
102
|
+
export interface CancelIntent {
|
|
103
|
+
/**
|
|
104
|
+
* `stop` — a user pressed Stop (`chat:cancel`): they are taking control back.
|
|
105
|
+
* `quiesce` — a destructive op is stopping the turn so it can safely mutate
|
|
106
|
+
* the transcript (#731); the chat is being deleted, reverted or promoted.
|
|
107
|
+
*/
|
|
108
|
+
reason: "stop" | "quiesce";
|
|
109
|
+
/** The socket that asked, if any. */
|
|
110
|
+
origin: HubSocket | null;
|
|
111
|
+
}
|
|
101
112
|
export declare class SessionHub {
|
|
102
113
|
/** sessionId → the current (running or recently-completed) turn for that session. */
|
|
103
114
|
private bySession;
|
|
@@ -109,6 +120,51 @@ export declare class SessionHub {
|
|
|
109
120
|
* button restore (#52) and streaming indicators (#53). Set by the WS layer.
|
|
110
121
|
*/
|
|
111
122
|
onActive: ((info: ActiveInfo) => void) | null;
|
|
123
|
+
/**
|
|
124
|
+
* Notified once per turn, when that turn stops running (#627). Set by the WS
|
|
125
|
+
* layer, which uses it to drain a queued follow-up.
|
|
126
|
+
*
|
|
127
|
+
* This exists because "after a turn" was, for a long time, spelled as a call at
|
|
128
|
+
* ONE of the eight `turn.end()` sites — the `chat:send` completion. A message
|
|
129
|
+
* queued during a slash command, a trigger/spawn turn, a scheduler wake, a
|
|
130
|
+
* background sub-agent stretch or a Stopped turn was therefore persisted and
|
|
131
|
+
* then stranded, escaping only when some LATER `chat:send` happened to flush it —
|
|
132
|
+
* arriving after a message the user typed afterwards. Every one of those paths
|
|
133
|
+
* already funnels through {@link end}, so hanging the drain here makes "drain on
|
|
134
|
+
* every turn-ending path" structural rather than a list somebody has to remember
|
|
135
|
+
* to extend — including for turn paths that don't exist yet.
|
|
136
|
+
*
|
|
137
|
+
* Fires only once a turn's session id is known (a turn that died before its id
|
|
138
|
+
* resolved has no chat to drain), and always AFTER the terminal frame its caller
|
|
139
|
+
* emitted, so a drained follow-up's bubble lands below the turn it followed.
|
|
140
|
+
*/
|
|
141
|
+
onTurnEnd: ((info: {
|
|
142
|
+
sessionId: string;
|
|
143
|
+
projectSlug: string;
|
|
144
|
+
origin: HubSocket | null;
|
|
145
|
+
/** Set when this end was CAUSED by an explicit cancel — see {@link noteCancel}. */
|
|
146
|
+
cancel?: CancelIntent;
|
|
147
|
+
}) => void) | null;
|
|
148
|
+
/**
|
|
149
|
+
* Why a turn was cancelled, and by whom, pending the end it causes.
|
|
150
|
+
* Consumed by the next {@link end} for that session.
|
|
151
|
+
*/
|
|
152
|
+
private cancels;
|
|
153
|
+
/**
|
|
154
|
+
* Record that a cancel was just issued for `sessionId`, so the turn end it
|
|
155
|
+
* causes can be told apart from a turn that finished on its own.
|
|
156
|
+
*
|
|
157
|
+
* The distinction is not cosmetic: "the turn ended" and "the user took control
|
|
158
|
+
* back" call for opposite treatment of anything queued behind that turn. A
|
|
159
|
+
* `stop` hands the queued message back to the client that pressed Stop; a
|
|
160
|
+
* `quiesce` (the destructive-op interlock, #731) must not touch it at all —
|
|
161
|
+
* that caller is about to delete or rewrite the transcript, and starting a
|
|
162
|
+
* fresh turn into it is the resurrection #730 was about.
|
|
163
|
+
*
|
|
164
|
+
* `origin` is the socket that asked, so the WS layer knows where to hand the
|
|
165
|
+
* message back; null when the cancel came from no socket at all.
|
|
166
|
+
*/
|
|
167
|
+
noteCancel(sessionId: string, intent: CancelIntent): void;
|
|
112
168
|
/**
|
|
113
169
|
* Begin tracking a turn. Pass `sessionId` when it's already known (a resumed
|
|
114
170
|
* chat / a slash command) so the turn is re-attachable from its very first
|
|
@@ -143,7 +199,19 @@ export declare class SessionHub {
|
|
|
143
199
|
* like `chat:queued_flushed` that must reach a client that reconnected on a new
|
|
144
200
|
* socket after the origin died.
|
|
145
201
|
*/
|
|
146
|
-
broadcast(sessionId: string, msg: HubMessage
|
|
202
|
+
broadcast(sessionId: string, msg: HubMessage, opts?: {
|
|
203
|
+
except?: HubSocket | null;
|
|
204
|
+
}): void;
|
|
205
|
+
/**
|
|
206
|
+
* The session a cancellable job id belongs to, or null if no tracked turn
|
|
207
|
+
* carries it. `chat:cancel` names only a job id, so this is how the WS layer
|
|
208
|
+
* learns WHICH chat a Stop was aimed at. Covers a background stretch too — it
|
|
209
|
+
* publishes its synthetic id on its own hub turn (#528).
|
|
210
|
+
*/
|
|
211
|
+
sessionForJob(jobId: string): {
|
|
212
|
+
sessionId: string;
|
|
213
|
+
projectSlug: string;
|
|
214
|
+
} | null;
|
|
147
215
|
/** Whether a session currently has a RUNNING turn plus its job id, or null. */
|
|
148
216
|
activeInfo(sessionId: string): ActiveInfo | null;
|
|
149
217
|
/** Snapshot of every session with a running turn (for a newly-connected client). */
|
|
@@ -50,6 +50,47 @@ export class SessionHub {
|
|
|
50
50
|
* button restore (#52) and streaming indicators (#53). Set by the WS layer.
|
|
51
51
|
*/
|
|
52
52
|
onActive = null;
|
|
53
|
+
/**
|
|
54
|
+
* Notified once per turn, when that turn stops running (#627). Set by the WS
|
|
55
|
+
* layer, which uses it to drain a queued follow-up.
|
|
56
|
+
*
|
|
57
|
+
* This exists because "after a turn" was, for a long time, spelled as a call at
|
|
58
|
+
* ONE of the eight `turn.end()` sites — the `chat:send` completion. A message
|
|
59
|
+
* queued during a slash command, a trigger/spawn turn, a scheduler wake, a
|
|
60
|
+
* background sub-agent stretch or a Stopped turn was therefore persisted and
|
|
61
|
+
* then stranded, escaping only when some LATER `chat:send` happened to flush it —
|
|
62
|
+
* arriving after a message the user typed afterwards. Every one of those paths
|
|
63
|
+
* already funnels through {@link end}, so hanging the drain here makes "drain on
|
|
64
|
+
* every turn-ending path" structural rather than a list somebody has to remember
|
|
65
|
+
* to extend — including for turn paths that don't exist yet.
|
|
66
|
+
*
|
|
67
|
+
* Fires only once a turn's session id is known (a turn that died before its id
|
|
68
|
+
* resolved has no chat to drain), and always AFTER the terminal frame its caller
|
|
69
|
+
* emitted, so a drained follow-up's bubble lands below the turn it followed.
|
|
70
|
+
*/
|
|
71
|
+
onTurnEnd = null;
|
|
72
|
+
/**
|
|
73
|
+
* Why a turn was cancelled, and by whom, pending the end it causes.
|
|
74
|
+
* Consumed by the next {@link end} for that session.
|
|
75
|
+
*/
|
|
76
|
+
cancels = new Map();
|
|
77
|
+
/**
|
|
78
|
+
* Record that a cancel was just issued for `sessionId`, so the turn end it
|
|
79
|
+
* causes can be told apart from a turn that finished on its own.
|
|
80
|
+
*
|
|
81
|
+
* The distinction is not cosmetic: "the turn ended" and "the user took control
|
|
82
|
+
* back" call for opposite treatment of anything queued behind that turn. A
|
|
83
|
+
* `stop` hands the queued message back to the client that pressed Stop; a
|
|
84
|
+
* `quiesce` (the destructive-op interlock, #731) must not touch it at all —
|
|
85
|
+
* that caller is about to delete or rewrite the transcript, and starting a
|
|
86
|
+
* fresh turn into it is the resurrection #730 was about.
|
|
87
|
+
*
|
|
88
|
+
* `origin` is the socket that asked, so the WS layer knows where to hand the
|
|
89
|
+
* message back; null when the cancel came from no socket at all.
|
|
90
|
+
*/
|
|
91
|
+
noteCancel(sessionId, intent) {
|
|
92
|
+
this.cancels.set(sessionId, intent);
|
|
93
|
+
}
|
|
53
94
|
/**
|
|
54
95
|
* Begin tracking a turn. Pass `sessionId` when it's already known (a resumed
|
|
55
96
|
* chat / a slash command) so the turn is re-attachable from its very first
|
|
@@ -129,7 +170,7 @@ export class SessionHub {
|
|
|
129
170
|
* like `chat:queued_flushed` that must reach a client that reconnected on a new
|
|
130
171
|
* socket after the origin died.
|
|
131
172
|
*/
|
|
132
|
-
broadcast(sessionId, msg) {
|
|
173
|
+
broadcast(sessionId, msg, opts = {}) {
|
|
133
174
|
const set = new Set();
|
|
134
175
|
const turn = this.bySession.get(sessionId);
|
|
135
176
|
if (turn?.origin)
|
|
@@ -138,9 +179,24 @@ export class SessionHub {
|
|
|
138
179
|
if (subs)
|
|
139
180
|
for (const s of subs)
|
|
140
181
|
set.add(s);
|
|
182
|
+
if (opts.except)
|
|
183
|
+
set.delete(opts.except);
|
|
141
184
|
for (const s of set)
|
|
142
185
|
SessionHub.write(s, msg);
|
|
143
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* The session a cancellable job id belongs to, or null if no tracked turn
|
|
189
|
+
* carries it. `chat:cancel` names only a job id, so this is how the WS layer
|
|
190
|
+
* learns WHICH chat a Stop was aimed at. Covers a background stretch too — it
|
|
191
|
+
* publishes its synthetic id on its own hub turn (#528).
|
|
192
|
+
*/
|
|
193
|
+
sessionForJob(jobId) {
|
|
194
|
+
for (const [sessionId, turn] of this.bySession) {
|
|
195
|
+
if (turn.jobId === jobId)
|
|
196
|
+
return { sessionId, projectSlug: turn.projectSlug };
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
144
200
|
// --- internals, driven by TurnHandle -------------------------------------
|
|
145
201
|
/** Whether a session currently has a RUNNING turn plus its job id, or null. */
|
|
146
202
|
activeInfo(sessionId) {
|
|
@@ -191,11 +247,27 @@ export class SessionHub {
|
|
|
191
247
|
}
|
|
192
248
|
/** Mark a turn finished; retain its buffer briefly for an end-of-turn reconnect. */
|
|
193
249
|
end(turn) {
|
|
250
|
+
if (!turn.running)
|
|
251
|
+
return; // already ended — never fire onTurnEnd twice
|
|
194
252
|
turn.running = false;
|
|
195
253
|
if (turn.sessionId) {
|
|
196
254
|
this.fireActive(turn);
|
|
197
255
|
turn.evictTimer = setTimeout(() => this.evict(turn), COMPLETED_TTL_MS);
|
|
198
256
|
turn.evictTimer.unref?.();
|
|
257
|
+
// #627: the session is idle again — let the WS layer deal with anything
|
|
258
|
+
// queued behind this turn. Never allowed to throw into a caller finalizing
|
|
259
|
+
// its turn. A pending cancel intent belongs to THIS end, so consume it.
|
|
260
|
+
const cancel = this.cancels.get(turn.sessionId);
|
|
261
|
+
this.cancels.delete(turn.sessionId);
|
|
262
|
+
if (this.onTurnEnd) {
|
|
263
|
+
const { sessionId, projectSlug, origin } = turn;
|
|
264
|
+
try {
|
|
265
|
+
this.onTurnEnd({ sessionId, projectSlug, origin, ...(cancel ? { cancel } : {}) });
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
/* a drain that throws must not break turn teardown */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
199
271
|
}
|
|
200
272
|
}
|
|
201
273
|
/**
|
|
@@ -29,6 +29,14 @@ export async function quiesceSession(deps, sessionId, opts = {}) {
|
|
|
29
29
|
const deadline = Date.now() + (opts.timeoutMs ?? DEFAULT_QUIESCE_TIMEOUT_MS);
|
|
30
30
|
const cancelled = new Set();
|
|
31
31
|
let reaped = false;
|
|
32
|
+
// Mark the turn end this is about to cause as OURS, not a user's Stop. The WS
|
|
33
|
+
// layer treats the two oppositely for anything queued behind the turn: a Stop
|
|
34
|
+
// hands the message back to the user's composer, while this caller is about to
|
|
35
|
+
// delete, revert or promote the transcript — so the queue must be left strictly
|
|
36
|
+
// alone. Draining it here would start a fresh turn on a chat being removed (the
|
|
37
|
+
// #730 resurrection), and would make the session busy again so the poll below
|
|
38
|
+
// could never settle.
|
|
39
|
+
deps.hub?.noteCancel(sessionId, { reason: "quiesce", origin: null });
|
|
32
40
|
for (;;) {
|
|
33
41
|
const jobId = deps.hub?.activeInfo(sessionId)?.jobId ?? null;
|
|
34
42
|
if (jobId && !cancelled.has(jobId)) {
|
|
@@ -214,10 +214,23 @@ export interface ChatSetQueueMessage {
|
|
|
214
214
|
/** The queued message text, or null/empty to clear. */
|
|
215
215
|
text?: string | null;
|
|
216
216
|
/**
|
|
217
|
-
*
|
|
218
|
-
* when
|
|
219
|
-
*
|
|
220
|
-
*
|
|
217
|
+
* OPAQUE identity of this client's queue (#245 stable identity, #736). Minted
|
|
218
|
+
* once when the queue is first created and kept across edits, appends and
|
|
219
|
+
* reloads, so the server can tell (a) this client updating its own queue apart
|
|
220
|
+
* from another client queueing alongside it (#629) and (b) a stale re-assert of
|
|
221
|
+
* an already-drained message apart from a new one — by EQUALITY, never ordering.
|
|
222
|
+
*
|
|
223
|
+
* Absent from an older client, which sends only `ts`; the server folds that
|
|
224
|
+
* into an id.
|
|
225
|
+
*/
|
|
226
|
+
qid?: string | null;
|
|
227
|
+
/**
|
|
228
|
+
* Legacy client-stamped enqueue time (#245). Superseded by `qid`: it was used
|
|
229
|
+
* as an ORDERED identity, so one client with a fast clock poisoned the dedup
|
|
230
|
+
* marker and every later queued message on that chat — from any client — was
|
|
231
|
+
* silently destroyed (#736). Still accepted (older clients send it, and it
|
|
232
|
+
* still identifies a message) but only ever compared for equality, and never
|
|
233
|
+
* stored as the enqueue time — the server stamps that itself.
|
|
221
234
|
*/
|
|
222
235
|
ts?: number | null;
|
|
223
236
|
};
|
|
@@ -382,6 +395,64 @@ export interface ChatQueuedFlushedMessage {
|
|
|
382
395
|
text?: string;
|
|
383
396
|
};
|
|
384
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* The chat's queued message changed (#629). Broadcast to EVERY socket attached to
|
|
400
|
+
* the session whenever the slot is written, so the queue is shared chat state that
|
|
401
|
+
* all clients render identically instead of invisible per-client state.
|
|
402
|
+
*
|
|
403
|
+
* Before this, a queue was only ever written and never announced: a second client
|
|
404
|
+
* queueing on the same chat overwrote the first client's text with no signal to
|
|
405
|
+
* anyone. The first user's chip sat there showing a message that no longer existed,
|
|
406
|
+
* and when the drain finally fired, their transcript rendered — as their own user
|
|
407
|
+
* bubble — a message they had never typed. The slot now MERGES contributions, and
|
|
408
|
+
* this frame is what makes the merge visible.
|
|
409
|
+
*/
|
|
410
|
+
export interface ChatQueuedStateMessage {
|
|
411
|
+
type: "chat:queued_state";
|
|
412
|
+
payload: {
|
|
413
|
+
projectSlug: string;
|
|
414
|
+
sessionId: string;
|
|
415
|
+
/** The chat's full queued text, or null when the slot is now empty. */
|
|
416
|
+
text: string | null;
|
|
417
|
+
/**
|
|
418
|
+
* The slot's identity. A client adopts it as its own queue id, so its next
|
|
419
|
+
* edit updates this shared slot in place rather than appending beside it.
|
|
420
|
+
*/
|
|
421
|
+
qid?: string;
|
|
422
|
+
/**
|
|
423
|
+
* Why the slot changed, when it is worth explaining. `returned` means a user
|
|
424
|
+
* pressed Stop and the message went back to THEIR composer — sent to the
|
|
425
|
+
* other clients so a chip that just vanished from under them has a reason
|
|
426
|
+
* attached, rather than looking like the message evaporated. The client that
|
|
427
|
+
* pressed Stop is excluded from this broadcast; it gets
|
|
428
|
+
* {@link ChatQueuedReturnedMessage} instead.
|
|
429
|
+
*/
|
|
430
|
+
reason?: "returned";
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* A user pressed Stop, so the message queued behind that turn is handed BACK to
|
|
435
|
+
* them (#751 follow-up). Sent only to the socket that asked to cancel.
|
|
436
|
+
*
|
|
437
|
+
* Stop means "give me control back". Auto-sending the follow-up would have the
|
|
438
|
+
* agent start working again the instant the user stopped it, and holding the
|
|
439
|
+
* message server-side is what stranded it (#627) — so it returns to the composer,
|
|
440
|
+
* where it is visible, editable, and sent only when the user says so.
|
|
441
|
+
*
|
|
442
|
+
* Deliberately NOT `chat:queued_flushed` with a flag: that frame means "this text
|
|
443
|
+
* was sent, render it as the user's bubble", and a returned message is precisely
|
|
444
|
+
* one that was NOT sent. Overloading it risks a phantom user turn in the
|
|
445
|
+
* transcript for a message the agent never received.
|
|
446
|
+
*/
|
|
447
|
+
export interface ChatQueuedReturnedMessage {
|
|
448
|
+
type: "chat:queued_returned";
|
|
449
|
+
payload: {
|
|
450
|
+
projectSlug: string;
|
|
451
|
+
sessionId: string;
|
|
452
|
+
/** The queued text, for the client to put back in its composer. */
|
|
453
|
+
text: string;
|
|
454
|
+
};
|
|
455
|
+
}
|
|
385
456
|
/**
|
|
386
457
|
* A background task was killed at the turn boundary and the keeper is idle
|
|
387
458
|
* (issue #347). Broadcast LIVE by the recovery engine the moment the kill is
|
|
@@ -418,6 +489,6 @@ export interface ChatNoticeMessage {
|
|
|
418
489
|
export interface PongMessage {
|
|
419
490
|
type: "pong";
|
|
420
491
|
}
|
|
421
|
-
export type ServerMessage = ChatResponseMessage | ChatToolCallMessage | ChatToolStartMessage | ChatMessageBoundaryMessage | ChatCompleteMessage | ChatErrorMessage | ChatResyncMessage | ChatActiveMessage | ChatQueuedFlushedMessage | ChatKilledTaskMessage | ChatNoticeMessage | PongMessage;
|
|
492
|
+
export type ServerMessage = ChatResponseMessage | ChatToolCallMessage | ChatToolStartMessage | ChatMessageBoundaryMessage | ChatCompleteMessage | ChatErrorMessage | ChatResyncMessage | ChatActiveMessage | ChatQueuedFlushedMessage | ChatQueuedStateMessage | ChatQueuedReturnedMessage | ChatKilledTaskMessage | ChatNoticeMessage | PongMessage;
|
|
422
493
|
export declare function isClientMessage(data: unknown): data is ClientMessage;
|
|
423
494
|
export {};
|
|
@@ -245,6 +245,8 @@ export function isClientMessage(data) {
|
|
|
245
245
|
return false;
|
|
246
246
|
if (p.ts !== undefined && p.ts !== null && typeof p.ts !== "number")
|
|
247
247
|
return false;
|
|
248
|
+
if (p.qid !== undefined && p.qid !== null && typeof p.qid !== "string")
|
|
249
|
+
return false;
|
|
248
250
|
return true;
|
|
249
251
|
}
|
|
250
252
|
return false;
|
|
@@ -1,55 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WebSocket chat transport.
|
|
3
|
-
*
|
|
4
|
-
* Protocol (the contract the frontend agent matches):
|
|
5
|
-
*
|
|
6
|
-
* client -> server:
|
|
7
|
-
* { type: "chat:send", payload: {
|
|
8
|
-
* projectSlug: string, // workspace key: a project slug, or "" for the root
|
|
9
|
-
* sessionId: string | null, // resume an existing chat, or null = new
|
|
10
|
-
* message: string,
|
|
11
|
-
* preloadContext?: boolean, // new chat: prepend the project OVERVIEW.md
|
|
12
|
-
* model?: string, // per-chat model override (a known model id)
|
|
13
|
-
* } }
|
|
14
|
-
* { type: "chat:cancel", payload: { jobId } } // optional: stop a running turn
|
|
15
|
-
* { type: "ping" }
|
|
16
|
-
*
|
|
17
|
-
* server -> client (all carry projectSlug + sessionId + jobId for routing):
|
|
18
|
-
* { type: "chat:response", payload: { projectSlug, sessionId, jobId, chunk } }
|
|
19
|
-
* { type: "chat:tool_call", payload: { projectSlug, sessionId, jobId,
|
|
20
|
-
* toolName, inputSummary, output,
|
|
21
|
-
* isError, durationMs } }
|
|
22
|
-
* { type: "chat:message_boundary", payload: { projectSlug, sessionId, jobId } }
|
|
23
|
-
* { type: "chat:complete", payload: { projectSlug, sessionId, jobId, success, error?,
|
|
24
|
-
* model?, usage? } }
|
|
25
|
-
* // model: the model the turn ran on (lastModel ?? effectiveModel).
|
|
26
|
-
* // usage: { inputTokens, outputTokens, cacheReadTokens,
|
|
27
|
-
* // cacheCreationTokens, contextTokens, contextLimit } — the
|
|
28
|
-
* // LAST per-turn usage observed; omitted (with model) if none.
|
|
29
|
-
* // contextTokens = input + cacheRead + cacheCreation;
|
|
30
|
-
* // contextLimit = getContextLimit(model). Stale-by-one-turn
|
|
31
|
-
* // by design (it reflects the just-completed turn's input).
|
|
32
|
-
* { type: "chat:injected", payload: { projectSlug, sessionId, jobId,
|
|
33
|
-
* sender, content, timestamp } }
|
|
34
|
-
* // A machine-injected user turn (issue #290 Part 2): another chat
|
|
35
|
-
* // send_message'd / a schedule fired into this session. Emitted so a
|
|
36
|
-
* // client already viewing the recipient chat renders the injected user
|
|
37
|
-
* // bubble LIVE (with its sender attribution) instead of only seeing the
|
|
38
|
-
* // assistant reply and needing a refresh. `sender` is the MessageSender.
|
|
39
|
-
* { type: "chat:error", payload: { projectSlug, error } }
|
|
40
|
-
* { type: "pong" }
|
|
41
|
-
*
|
|
42
|
-
* Streaming is wired for real via HerdctlService.chat()'s onMessage callback
|
|
43
|
-
* (the public trigger API supports it). The SDKMessage -> chat-event translation
|
|
44
|
-
* (assistant text deltas, message boundaries, and paired tool_use -> tool_result
|
|
45
|
-
* calls enriched with input summaries + wall-clock durations) is done by
|
|
46
|
-
* @herdctl/chat's `createSDKMessageHandler` — the shared, transport-agnostic
|
|
47
|
-
* translator every herdctl chat surface uses — so paddock no longer reimplements
|
|
48
|
-
* it (and as of @herdctl/chat@0.4.1 it pairs CLI tool results correctly, so the
|
|
49
|
-
* prior `normalizeForTranslator` shim is gone). We compose it with a tiny wrapper
|
|
50
|
-
* that also captures, from each raw SDK message, the session id and the per-turn
|
|
51
|
-
* usage + model (the translator only exposes text/boundary/tool events).
|
|
52
|
-
*/
|
|
53
1
|
import type { WebSocket } from "@fastify/websocket";
|
|
54
2
|
export * from "./ws-protocol.js";
|
|
55
3
|
import type { ChatHandlerDeps } from "./ws-context.js";
|