@os-eco/overstory-cli 0.9.3 → 0.10.3

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 (116) hide show
  1. package/README.md +49 -18
  2. package/agents/builder.md +9 -8
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +98 -82
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +211 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/overlay.test.ts +4 -4
  18. package/src/agents/overlay.ts +30 -8
  19. package/src/agents/turn-lock.test.ts +181 -0
  20. package/src/agents/turn-lock.ts +235 -0
  21. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  22. package/src/agents/turn-runner-dispatch.ts +105 -0
  23. package/src/agents/turn-runner.test.ts +1450 -0
  24. package/src/agents/turn-runner.ts +1166 -0
  25. package/src/commands/clean.ts +56 -1
  26. package/src/commands/completions.test.ts +4 -1
  27. package/src/commands/coordinator.test.ts +127 -0
  28. package/src/commands/coordinator.ts +205 -6
  29. package/src/commands/dashboard.test.ts +188 -0
  30. package/src/commands/dashboard.ts +13 -3
  31. package/src/commands/doctor.ts +94 -77
  32. package/src/commands/group.test.ts +94 -0
  33. package/src/commands/group.ts +49 -20
  34. package/src/commands/init.test.ts +8 -0
  35. package/src/commands/init.ts +8 -1
  36. package/src/commands/log.test.ts +56 -11
  37. package/src/commands/log.ts +134 -69
  38. package/src/commands/mail.test.ts +162 -0
  39. package/src/commands/mail.ts +64 -9
  40. package/src/commands/merge.test.ts +112 -1
  41. package/src/commands/merge.ts +17 -4
  42. package/src/commands/monitor.ts +2 -1
  43. package/src/commands/nudge.test.ts +351 -4
  44. package/src/commands/nudge.ts +356 -34
  45. package/src/commands/run.test.ts +43 -7
  46. package/src/commands/serve/build.test.ts +202 -0
  47. package/src/commands/serve/build.ts +206 -0
  48. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  49. package/src/commands/serve/coordinator-actions.ts +408 -0
  50. package/src/commands/serve/dev.test.ts +168 -0
  51. package/src/commands/serve/dev.ts +117 -0
  52. package/src/commands/serve/mail-actions.test.ts +312 -0
  53. package/src/commands/serve/mail-actions.ts +167 -0
  54. package/src/commands/serve/rest.test.ts +1323 -0
  55. package/src/commands/serve/rest.ts +708 -0
  56. package/src/commands/serve/static.ts +51 -0
  57. package/src/commands/serve/ws.test.ts +361 -0
  58. package/src/commands/serve/ws.ts +332 -0
  59. package/src/commands/serve.test.ts +459 -0
  60. package/src/commands/serve.ts +565 -0
  61. package/src/commands/sling.test.ts +85 -1
  62. package/src/commands/sling.ts +153 -64
  63. package/src/commands/status.test.ts +9 -0
  64. package/src/commands/status.ts +12 -4
  65. package/src/commands/stop.test.ts +174 -1
  66. package/src/commands/stop.ts +107 -8
  67. package/src/commands/supervisor.ts +2 -1
  68. package/src/commands/watch.test.ts +49 -4
  69. package/src/commands/watch.ts +153 -28
  70. package/src/commands/worktree.test.ts +319 -3
  71. package/src/commands/worktree.ts +86 -0
  72. package/src/config.test.ts +78 -0
  73. package/src/config.ts +43 -1
  74. package/src/doctor/consistency.test.ts +106 -0
  75. package/src/doctor/consistency.ts +50 -3
  76. package/src/doctor/serve.test.ts +95 -0
  77. package/src/doctor/serve.ts +86 -0
  78. package/src/doctor/types.ts +2 -1
  79. package/src/doctor/watchdog.ts +57 -1
  80. package/src/events/tailer.test.ts +234 -1
  81. package/src/events/tailer.ts +90 -0
  82. package/src/index.ts +53 -6
  83. package/src/json.ts +29 -0
  84. package/src/mail/client.ts +15 -2
  85. package/src/mail/store.test.ts +82 -0
  86. package/src/mail/store.ts +41 -4
  87. package/src/merge/lock.test.ts +149 -0
  88. package/src/merge/lock.ts +140 -0
  89. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  90. package/src/runtimes/claude.test.ts +791 -1
  91. package/src/runtimes/claude.ts +323 -1
  92. package/src/runtimes/connections.test.ts +141 -1
  93. package/src/runtimes/connections.ts +73 -4
  94. package/src/runtimes/headless-connection.test.ts +264 -0
  95. package/src/runtimes/headless-connection.ts +158 -0
  96. package/src/runtimes/types.ts +10 -0
  97. package/src/schema-consistency.test.ts +1 -0
  98. package/src/sessions/store.test.ts +390 -24
  99. package/src/sessions/store.ts +184 -19
  100. package/src/test-setup.test.ts +31 -0
  101. package/src/test-setup.ts +28 -0
  102. package/src/types.ts +56 -1
  103. package/src/utils/pid.test.ts +85 -1
  104. package/src/utils/pid.ts +86 -1
  105. package/src/utils/process-scan.test.ts +53 -0
  106. package/src/utils/process-scan.ts +76 -0
  107. package/src/watchdog/daemon.test.ts +1520 -411
  108. package/src/watchdog/daemon.ts +442 -83
  109. package/src/watchdog/health.test.ts +157 -0
  110. package/src/watchdog/health.ts +92 -25
  111. package/src/worktree/process.test.ts +71 -0
  112. package/src/worktree/process.ts +25 -5
  113. package/src/worktree/tmux.test.ts +39 -0
  114. package/src/worktree/tmux.ts +23 -3
  115. package/templates/CLAUDE.md.tmpl +19 -8
  116. package/templates/overlay.md.tmpl +3 -2
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Unit tests for the operator mail action helpers.
3
+ *
4
+ * Uses real SQLite stores in temp directories — no mocks. Each test gets a
5
+ * fresh mail.db / sessions.db pair so tests are fully isolated.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { ValidationError } from "../../errors.ts";
13
+ import type { MailStore } from "../../mail/store.ts";
14
+ import { createMailStore } from "../../mail/store.ts";
15
+ import type { SessionStore } from "../../sessions/store.ts";
16
+ import { createSessionStore } from "../../sessions/store.ts";
17
+ import type { AgentSession } from "../../types.ts";
18
+ import { deleteMail, replyMail, sendMail } from "./mail-actions.ts";
19
+
20
+ interface TestContext {
21
+ tempDir: string;
22
+ mail: MailStore;
23
+ session: SessionStore;
24
+ }
25
+
26
+ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
27
+ const id = `sess-${Math.random().toString(36).slice(2)}`;
28
+ return {
29
+ id,
30
+ agentName: overrides.agentName ?? `agent-${Math.random().toString(36).slice(2)}`,
31
+ capability: overrides.capability ?? "builder",
32
+ worktreePath: "/tmp/wt",
33
+ branchName: "branch",
34
+ taskId: "task-1",
35
+ tmuxSession: "tmux-sess",
36
+ state: overrides.state ?? "working",
37
+ pid: null,
38
+ parentAgent: null,
39
+ depth: 0,
40
+ runId: null,
41
+ startedAt: overrides.startedAt ?? new Date().toISOString(),
42
+ lastActivity: new Date().toISOString(),
43
+ escalationLevel: 0,
44
+ stalledSince: null,
45
+ transcriptPath: null,
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ describe("mail-actions", () => {
51
+ let ctx: TestContext;
52
+
53
+ beforeEach(() => {
54
+ const tempDir = mkdtempSync(join(tmpdir(), "overstory-mail-actions-"));
55
+ const mail = createMailStore(join(tempDir, "mail.db"));
56
+ const session = createSessionStore(join(tempDir, "sessions.db"));
57
+ ctx = { tempDir, mail, session };
58
+ });
59
+
60
+ afterEach(() => {
61
+ ctx.mail.close();
62
+ ctx.session.close();
63
+ rmSync(ctx.tempDir, { recursive: true, force: true });
64
+ });
65
+
66
+ // ─── sendMail ─────────────────────────────────────────────────────────────
67
+
68
+ describe("sendMail", () => {
69
+ test("writes a row for a concrete recipient", () => {
70
+ ctx.session.upsert(makeSession({ agentName: "alice", capability: "builder" }));
71
+
72
+ const result = sendMail(ctx, {
73
+ to: "alice",
74
+ subject: "hi",
75
+ body: "hello",
76
+ });
77
+
78
+ expect(result).toHaveProperty("messageId");
79
+ const id = (result as { messageId: string }).messageId;
80
+ const row = ctx.mail.getById(id);
81
+ expect(row).not.toBeNull();
82
+ expect(row?.to).toBe("alice");
83
+ expect(row?.subject).toBe("hi");
84
+ expect(row?.body).toBe("hello");
85
+ });
86
+
87
+ test("defaults from to 'operator' and type to 'status'", () => {
88
+ ctx.session.upsert(makeSession({ agentName: "bob" }));
89
+
90
+ const result = sendMail(ctx, { to: "bob", subject: "s", body: "b" });
91
+ const id = (result as { messageId: string }).messageId;
92
+ const row = ctx.mail.getById(id);
93
+
94
+ expect(row?.from).toBe("operator");
95
+ expect(row?.type).toBe("status");
96
+ expect(row?.priority).toBe("normal");
97
+ });
98
+
99
+ test("accepts an explicit from override", () => {
100
+ ctx.session.upsert(makeSession({ agentName: "carol" }));
101
+
102
+ const result = sendMail(ctx, {
103
+ to: "carol",
104
+ from: "alice",
105
+ subject: "s",
106
+ body: "b",
107
+ });
108
+ const id = (result as { messageId: string }).messageId;
109
+ expect(ctx.mail.getById(id)?.from).toBe("alice");
110
+ });
111
+
112
+ test("rejects unknown type with ValidationError", () => {
113
+ ctx.session.upsert(makeSession({ agentName: "dora" }));
114
+
115
+ expect(() => sendMail(ctx, { to: "dora", subject: "s", body: "b", type: "bogus" })).toThrow(
116
+ ValidationError,
117
+ );
118
+ });
119
+
120
+ test("rejects unknown priority with ValidationError", () => {
121
+ ctx.session.upsert(makeSession({ agentName: "eve" }));
122
+
123
+ expect(() => sendMail(ctx, { to: "eve", subject: "s", body: "b", priority: "huge" })).toThrow(
124
+ ValidationError,
125
+ );
126
+ });
127
+
128
+ test("rejects unknown recipient with ValidationError", () => {
129
+ expect(() => sendMail(ctx, { to: "ghost", subject: "s", body: "b" })).toThrow(
130
+ ValidationError,
131
+ );
132
+ });
133
+
134
+ test("rejects missing required fields", () => {
135
+ expect(() => sendMail(ctx, { subject: "s", body: "b" })).toThrow(ValidationError);
136
+ expect(() => sendMail(ctx, { to: "x", body: "b" })).toThrow(ValidationError);
137
+ expect(() => sendMail(ctx, { to: "x", subject: "s" })).toThrow(ValidationError);
138
+ expect(() => sendMail(ctx, { to: "x", subject: "s", body: "" })).toThrow(ValidationError);
139
+ });
140
+
141
+ test("resolves @builders to all active builders and fans out", () => {
142
+ ctx.session.upsert(makeSession({ agentName: "b1", capability: "builder" }));
143
+ ctx.session.upsert(makeSession({ agentName: "b2", capability: "builder" }));
144
+ ctx.session.upsert(makeSession({ agentName: "s1", capability: "scout" }));
145
+
146
+ const result = sendMail(ctx, {
147
+ to: "@builders",
148
+ subject: "ping",
149
+ body: "all builders",
150
+ });
151
+
152
+ expect(result).toHaveProperty("messageIds");
153
+ const ids = (result as { messageIds: string[] }).messageIds;
154
+ expect(ids.length).toBe(2);
155
+ const recipients = ids.map((id) => ctx.mail.getById(id)?.to).sort();
156
+ expect(recipients).toEqual(["b1", "b2"]);
157
+ });
158
+
159
+ test("rejects @unknown group with ValidationError", () => {
160
+ ctx.session.upsert(makeSession({ agentName: "z1", capability: "builder" }));
161
+
162
+ expect(() => sendMail(ctx, { to: "@nope", subject: "s", body: "b" })).toThrow(
163
+ ValidationError,
164
+ );
165
+ });
166
+
167
+ test("rejects @builders when there are no active builders", () => {
168
+ ctx.session.upsert(makeSession({ agentName: "s1", capability: "scout" }));
169
+
170
+ expect(() => sendMail(ctx, { to: "@builders", subject: "s", body: "b" })).toThrow(
171
+ ValidationError,
172
+ );
173
+ });
174
+
175
+ test("excludes sender from group fan-out", () => {
176
+ ctx.session.upsert(makeSession({ agentName: "b1", capability: "builder" }));
177
+ ctx.session.upsert(makeSession({ agentName: "b2", capability: "builder" }));
178
+
179
+ const result = sendMail(ctx, {
180
+ to: "@builders",
181
+ from: "b1",
182
+ subject: "s",
183
+ body: "b",
184
+ });
185
+
186
+ const ids = (result as { messageIds: string[] }).messageIds;
187
+ expect(ids.length).toBe(1);
188
+ expect(ctx.mail.getById(ids[0] ?? "")?.to).toBe("b2");
189
+ });
190
+
191
+ test("persists payload when provided", () => {
192
+ ctx.session.upsert(makeSession({ agentName: "alice" }));
193
+
194
+ const payload = JSON.stringify({ foo: 1 });
195
+ const result = sendMail(ctx, {
196
+ to: "alice",
197
+ subject: "s",
198
+ body: "b",
199
+ payload,
200
+ });
201
+ const id = (result as { messageId: string }).messageId;
202
+ expect(ctx.mail.getById(id)?.payload).toBe(payload);
203
+ });
204
+
205
+ test("accepts a completed session as a known historical recipient", () => {
206
+ ctx.session.upsert(makeSession({ agentName: "ghost-of-builders-past", state: "completed" }));
207
+
208
+ const result = sendMail(ctx, {
209
+ to: "ghost-of-builders-past",
210
+ subject: "s",
211
+ body: "b",
212
+ });
213
+ expect(result).toHaveProperty("messageId");
214
+ });
215
+ });
216
+
217
+ // ─── replyMail ────────────────────────────────────────────────────────────
218
+
219
+ describe("replyMail", () => {
220
+ test("writes a reply on the thread and returns the new id", () => {
221
+ const original = ctx.mail.insert({
222
+ id: "msg-orig",
223
+ from: "alice",
224
+ to: "operator",
225
+ subject: "Hi",
226
+ body: "first",
227
+ type: "status",
228
+ priority: "normal",
229
+ threadId: null,
230
+ });
231
+
232
+ const result = replyMail(ctx, original.id, { body: "thanks" });
233
+
234
+ expect(result.messageId).toBeTruthy();
235
+ const reply = ctx.mail.getById(result.messageId);
236
+ expect(reply?.from).toBe("operator");
237
+ expect(reply?.to).toBe("alice");
238
+ expect(reply?.body).toBe("thanks");
239
+ expect(reply?.threadId).toBe(original.id);
240
+ expect(reply?.subject).toBe("Re: Hi");
241
+ });
242
+
243
+ test("ignores type and priority in the reply payload (forward compat)", () => {
244
+ const original = ctx.mail.insert({
245
+ id: "msg-orig-2",
246
+ from: "alice",
247
+ to: "operator",
248
+ subject: "Hi",
249
+ body: "first",
250
+ type: "question",
251
+ priority: "high",
252
+ threadId: null,
253
+ });
254
+
255
+ const result = replyMail(ctx, original.id, {
256
+ body: "ack",
257
+ type: "bogus",
258
+ priority: "huge",
259
+ });
260
+
261
+ const reply = ctx.mail.getById(result.messageId);
262
+ // Reply inherits type/priority from the original — body fields are ignored.
263
+ expect(reply?.type).toBe("question");
264
+ expect(reply?.priority).toBe("high");
265
+ });
266
+
267
+ test("throws ValidationError when message not found", () => {
268
+ expect(() => replyMail(ctx, "msg-missing", { body: "x" })).toThrow(ValidationError);
269
+ });
270
+
271
+ test("rejects empty body with ValidationError", () => {
272
+ const original = ctx.mail.insert({
273
+ id: "msg-3",
274
+ from: "alice",
275
+ to: "operator",
276
+ subject: "Hi",
277
+ body: "first",
278
+ type: "status",
279
+ priority: "normal",
280
+ threadId: null,
281
+ });
282
+
283
+ expect(() => replyMail(ctx, original.id, { body: "" })).toThrow(ValidationError);
284
+ expect(() => replyMail(ctx, original.id, {})).toThrow(ValidationError);
285
+ });
286
+ });
287
+
288
+ // ─── deleteMail ───────────────────────────────────────────────────────────
289
+
290
+ describe("deleteMail", () => {
291
+ test("happy path returns { deleted: true } and removes the row", () => {
292
+ const msg = ctx.mail.insert({
293
+ id: "msg-del",
294
+ from: "a",
295
+ to: "b",
296
+ subject: "s",
297
+ body: "b",
298
+ type: "status",
299
+ priority: "normal",
300
+ threadId: null,
301
+ });
302
+
303
+ const result = deleteMail(ctx, msg.id);
304
+ expect(result).toEqual({ id: msg.id, deleted: true });
305
+ expect(ctx.mail.getById(msg.id)).toBeNull();
306
+ });
307
+
308
+ test("returns null when row is absent", () => {
309
+ expect(deleteMail(ctx, "msg-nope")).toBeNull();
310
+ });
311
+ });
312
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Pure-logic mail action helpers for the operator console REST API.
3
+ *
4
+ * Handlers in rest.ts parse JSON bodies and call these helpers, which
5
+ * validate input, resolve group addresses, and write to the mail store.
6
+ * Errors are signalled via {@link OverstoryError} subclasses so the route
7
+ * dispatcher can map them to HTTP status codes.
8
+ */
9
+
10
+ import { ValidationError } from "../../errors.ts";
11
+ import { isGroupAddress, resolveGroupAddress } from "../../mail/broadcast.ts";
12
+ import { createMailClient } from "../../mail/client.ts";
13
+ import type { MailStore } from "../../mail/store.ts";
14
+ import type { SessionStore } from "../../sessions/store.ts";
15
+ import type { MailMessage, MailMessageType } from "../../types.ts";
16
+ import { MAIL_MESSAGE_TYPES } from "../../types.ts";
17
+
18
+ export interface MailActionStores {
19
+ mail: MailStore;
20
+ session: SessionStore;
21
+ }
22
+
23
+ const VALID_TYPES: ReadonlySet<string> = new Set(MAIL_MESSAGE_TYPES);
24
+ const VALID_PRIORITIES: ReadonlySet<string> = new Set(["low", "normal", "high", "urgent"]);
25
+ type MailPriority = MailMessage["priority"];
26
+
27
+ export interface SendMailInput {
28
+ to?: unknown;
29
+ from?: unknown;
30
+ subject?: unknown;
31
+ body?: unknown;
32
+ type?: unknown;
33
+ priority?: unknown;
34
+ payload?: unknown;
35
+ }
36
+
37
+ export interface ReplyMailInput {
38
+ from?: unknown;
39
+ body?: unknown;
40
+ // Accepted for forward compatibility; reply inherits these from the original.
41
+ type?: unknown;
42
+ priority?: unknown;
43
+ }
44
+
45
+ export type SendMailResult = { messageId: string } | { messageIds: string[] };
46
+
47
+ function requireString(value: unknown, field: string): string {
48
+ if (typeof value !== "string" || value.length === 0) {
49
+ throw new ValidationError(`Missing or empty field: ${field}`, { field, value });
50
+ }
51
+ return value;
52
+ }
53
+
54
+ function optionalString(value: unknown, field: string, fallback: string): string {
55
+ if (value === undefined || value === null || value === "") return fallback;
56
+ if (typeof value !== "string") {
57
+ throw new ValidationError(`Field "${field}" must be a string`, { field, value });
58
+ }
59
+ return value;
60
+ }
61
+
62
+ /**
63
+ * Send a new mail message. If `to` is a group address (`@all`, `@builders`, ...)
64
+ * the message is fanned out to one row per resolved recipient.
65
+ */
66
+ export function sendMail(stores: MailActionStores, input: SendMailInput): SendMailResult {
67
+ const to = requireString(input.to, "to");
68
+ const subject = requireString(input.subject, "subject");
69
+ const body = requireString(input.body, "body");
70
+ const from = optionalString(input.from, "from", "operator");
71
+
72
+ const type = optionalString(input.type, "type", "status");
73
+ if (!VALID_TYPES.has(type)) {
74
+ throw new ValidationError(
75
+ `Invalid type: "${type}". Must be one of: ${MAIL_MESSAGE_TYPES.join(", ")}`,
76
+ { field: "type", value: type },
77
+ );
78
+ }
79
+
80
+ const priority = optionalString(input.priority, "priority", "normal");
81
+ if (!VALID_PRIORITIES.has(priority)) {
82
+ throw new ValidationError(
83
+ `Invalid priority: "${priority}". Must be one of: low, normal, high, urgent`,
84
+ { field: "priority", value: priority },
85
+ );
86
+ }
87
+
88
+ let payload: string | undefined;
89
+ if (input.payload !== undefined && input.payload !== null && input.payload !== "") {
90
+ if (typeof input.payload !== "string") {
91
+ throw new ValidationError(`Field "payload" must be a string`, {
92
+ field: "payload",
93
+ value: input.payload,
94
+ });
95
+ }
96
+ payload = input.payload;
97
+ }
98
+
99
+ const client = createMailClient(stores.mail);
100
+ const sendOne = (recipient: string): string =>
101
+ client.send({
102
+ from,
103
+ to: recipient,
104
+ subject,
105
+ body,
106
+ type: type as MailMessageType,
107
+ priority: priority as MailPriority,
108
+ payload,
109
+ });
110
+
111
+ if (isGroupAddress(to)) {
112
+ let recipients: string[];
113
+ try {
114
+ recipients = resolveGroupAddress(to, stores.session.getActive(), from);
115
+ } catch (err) {
116
+ const msg = err instanceof Error ? err.message : String(err);
117
+ throw new ValidationError(msg, { field: "to", value: to });
118
+ }
119
+ const ids = recipients.map(sendOne);
120
+ return { messageIds: ids };
121
+ }
122
+
123
+ if (stores.session.getByName(to) === null) {
124
+ throw new ValidationError(`Unknown recipient: "${to}"`, { field: "to", value: to });
125
+ }
126
+
127
+ return { messageId: sendOne(to) };
128
+ }
129
+
130
+ /**
131
+ * Reply to an existing message. Recipient, thread, type, and priority are
132
+ * inherited from the original via {@link MailClient.reply}.
133
+ *
134
+ * Throws {@link ValidationError} when the original is missing. The route
135
+ * handler also performs an upfront null-check so the HTTP response is 404,
136
+ * but this guard keeps the action safe to call programmatically.
137
+ */
138
+ export function replyMail(
139
+ stores: MailActionStores,
140
+ id: string,
141
+ input: ReplyMailInput,
142
+ ): { messageId: string } {
143
+ const body = requireString(input.body, "body");
144
+ const from = optionalString(input.from, "from", "operator");
145
+
146
+ const original = stores.mail.getById(id);
147
+ if (original === null) {
148
+ throw new ValidationError(`Message not found: ${id}`, { field: "id", value: id });
149
+ }
150
+
151
+ const client = createMailClient(stores.mail);
152
+ const messageId = client.reply(id, body, from);
153
+ return { messageId };
154
+ }
155
+
156
+ /**
157
+ * Delete a single message. Returns `null` when the row is absent so the route
158
+ * handler can map that case to HTTP 404.
159
+ */
160
+ export function deleteMail(
161
+ stores: MailActionStores,
162
+ id: string,
163
+ ): { id: string; deleted: true } | null {
164
+ const deleted = stores.mail.deleteById(id);
165
+ if (!deleted) return null;
166
+ return { id, deleted: true };
167
+ }