@os-eco/overstory-cli 0.9.4 → 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.
- package/README.md +47 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +211 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +203 -5
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +3 -1
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +73 -1
- package/src/commands/sling.ts +149 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +53 -6
- package/src/json.ts +29 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +3 -0
- package/src/worktree/tmux.ts +10 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
package/src/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { createOrchestratorCommand } from "./commands/orchestrator.ts";
|
|
|
36
36
|
import { primeCommand } from "./commands/prime.ts";
|
|
37
37
|
import { createReplayCommand } from "./commands/replay.ts";
|
|
38
38
|
import { createRunCommand } from "./commands/run.ts";
|
|
39
|
+
import { createServeCommand } from "./commands/serve.ts";
|
|
39
40
|
import { slingCommand } from "./commands/sling.ts";
|
|
40
41
|
import { specWriteCommand } from "./commands/spec.ts";
|
|
41
42
|
import { createStatusCommand } from "./commands/status.ts";
|
|
@@ -51,7 +52,7 @@ import { ConfigError, OverstoryError, WorktreeError } from "./errors.ts";
|
|
|
51
52
|
import { jsonError } from "./json.ts";
|
|
52
53
|
import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
|
|
53
54
|
|
|
54
|
-
export const VERSION = "0.
|
|
55
|
+
export const VERSION = "0.10.3";
|
|
55
56
|
|
|
56
57
|
const rawArgs = process.argv.slice(2);
|
|
57
58
|
|
|
@@ -103,6 +104,7 @@ const COMMANDS = [
|
|
|
103
104
|
"run",
|
|
104
105
|
"costs",
|
|
105
106
|
"metrics",
|
|
107
|
+
"serve",
|
|
106
108
|
"update",
|
|
107
109
|
"upgrade",
|
|
108
110
|
"completions",
|
|
@@ -204,9 +206,12 @@ program
|
|
|
204
206
|
},
|
|
205
207
|
});
|
|
206
208
|
|
|
207
|
-
// Apply global flags before any command action runs
|
|
208
|
-
|
|
209
|
-
|
|
209
|
+
// Apply global flags before any command action runs.
|
|
210
|
+
// `actionCommand` is the deepest command whose action is about to run (e.g.
|
|
211
|
+
// `coordinator start`); reading `optsWithGlobals()` on it walks up through
|
|
212
|
+
// every parent so subcommand-level `--project` flags are also seen.
|
|
213
|
+
program.hook("preAction", (_thisCmd, actionCommand) => {
|
|
214
|
+
const opts = actionCommand.optsWithGlobals();
|
|
210
215
|
if (opts.quiet) {
|
|
211
216
|
setQuiet(true);
|
|
212
217
|
}
|
|
@@ -225,8 +230,9 @@ program.hook("preAction", (thisCmd) => {
|
|
|
225
230
|
timingStart = performance.now();
|
|
226
231
|
}
|
|
227
232
|
});
|
|
228
|
-
program.hook("postAction", () => {
|
|
229
|
-
|
|
233
|
+
program.hook("postAction", (_thisCmd, actionCommand) => {
|
|
234
|
+
const opts = actionCommand.optsWithGlobals();
|
|
235
|
+
if (opts.timing && timingStart !== undefined) {
|
|
230
236
|
const elapsed = performance.now() - timingStart;
|
|
231
237
|
const formatted =
|
|
232
238
|
elapsed < 1000 ? `${Math.round(elapsed)}ms` : `${(elapsed / 1000).toFixed(2)}s`;
|
|
@@ -246,6 +252,7 @@ program.addCommand(createWorktreeCommand());
|
|
|
246
252
|
program.addCommand(createLogCommand());
|
|
247
253
|
program.addCommand(createWatchCommand());
|
|
248
254
|
program.addCommand(createGroupCommand());
|
|
255
|
+
program.addCommand(createServeCommand());
|
|
249
256
|
program.addCommand(createCompletionsCommand());
|
|
250
257
|
|
|
251
258
|
// Unmigrated commands — passthrough pattern
|
|
@@ -292,6 +299,14 @@ program
|
|
|
292
299
|
.option("--runtime <name>", "Runtime adapter (default: config or claude)")
|
|
293
300
|
.option("--base-branch <branch>", "Base branch for worktree creation (default: current HEAD)")
|
|
294
301
|
.option("--profile <name>", "Canopy profile to apply to agent overlay")
|
|
302
|
+
.option(
|
|
303
|
+
"--headless",
|
|
304
|
+
"Spawn through Bun.spawn (stream-json) instead of tmux. Requires runtime with buildDirectSpawn.",
|
|
305
|
+
)
|
|
306
|
+
.option(
|
|
307
|
+
"--recover",
|
|
308
|
+
"Allow dispatch against a task in any tracker status (e.g. closed). Use when a prior owner exited and the task needs a fresh agent.",
|
|
309
|
+
)
|
|
295
310
|
.option("--json", "Output result as JSON")
|
|
296
311
|
.action(async (taskId, opts) => {
|
|
297
312
|
await slingCommand(taskId, opts);
|
|
@@ -359,6 +374,7 @@ program
|
|
|
359
374
|
program
|
|
360
375
|
.command("mail")
|
|
361
376
|
.description("Mail system (send/check/list/read/reply)")
|
|
377
|
+
.helpOption(false)
|
|
362
378
|
.allowUnknownOption()
|
|
363
379
|
.allowExcessArguments()
|
|
364
380
|
.action(async (_opts, cmd) => {
|
|
@@ -422,6 +438,37 @@ program.addCommand(createUpdateCommand());
|
|
|
422
438
|
|
|
423
439
|
program.addCommand(createUpgradeCommand());
|
|
424
440
|
|
|
441
|
+
// Propagate root-level globals to every (sub)command so they can appear before
|
|
442
|
+
// or after the command name. With `enablePositionalOptions()`, options declared
|
|
443
|
+
// on the root program are not accepted after a subcommand name; copying them
|
|
444
|
+
// onto each command lets `ov status --project /path` work the same as
|
|
445
|
+
// `ov --project /path status`. Skips the delegated `mail`/`nudge`/`logs`/`trace`
|
|
446
|
+
// commands, which use `allowUnknownOption()` and forward args to an inner
|
|
447
|
+
// Commander parser. The preAction hook reads `actionCommand.optsWithGlobals()`,
|
|
448
|
+
// so it sees these regardless of which level they were parsed at.
|
|
449
|
+
const DELEGATED_COMMANDS = new Set(["mail", "nudge", "logs", "trace"]);
|
|
450
|
+
const PROPAGATED_GLOBALS: ReadonlyArray<readonly [string, string]> = [
|
|
451
|
+
["--project <path>", "Target project root (overrides auto-detection)"],
|
|
452
|
+
["-q, --quiet", "Suppress non-error output"],
|
|
453
|
+
["--timing", "Print command execution time to stderr"],
|
|
454
|
+
];
|
|
455
|
+
function propagateGlobalOptions(cmd: Command): void {
|
|
456
|
+
for (const sub of cmd.commands) {
|
|
457
|
+
if (sub === cmd) continue;
|
|
458
|
+
if (!DELEGATED_COMMANDS.has(sub.name())) {
|
|
459
|
+
for (const [flag, desc] of PROPAGATED_GLOBALS) {
|
|
460
|
+
const long = flag.split(/[\s,]+/).find((p) => p.startsWith("--"));
|
|
461
|
+
const alreadyDeclared = sub.options.some((o) => o.long === long);
|
|
462
|
+
if (!alreadyDeclared) {
|
|
463
|
+
sub.option(flag, desc);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
propagateGlobalOptions(sub);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
propagateGlobalOptions(program);
|
|
471
|
+
|
|
425
472
|
// Handle unknown commands with Levenshtein fuzzy-match suggestions
|
|
426
473
|
program.on("command:*", (operands) => {
|
|
427
474
|
const unknown = operands[0] ?? "";
|
package/src/json.ts
CHANGED
|
@@ -22,3 +22,32 @@ export function jsonOutput(command: string, data: Record<string, unknown>): void
|
|
|
22
22
|
export function jsonError(command: string, error: string): void {
|
|
23
23
|
process.stdout.write(`${JSON.stringify({ success: false, command, error })}\n`);
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a JSON success Response for HTTP API handlers.
|
|
28
|
+
* Envelope: { success: true, command: 'serve', data, nextCursor? }
|
|
29
|
+
*/
|
|
30
|
+
export function apiJson(
|
|
31
|
+
data: unknown,
|
|
32
|
+
init?: { status?: number; nextCursor?: string | null },
|
|
33
|
+
): Response {
|
|
34
|
+
const envelope: Record<string, unknown> = { success: true, command: "serve", data };
|
|
35
|
+
if (init?.nextCursor != null) {
|
|
36
|
+
envelope.nextCursor = init.nextCursor;
|
|
37
|
+
}
|
|
38
|
+
return new Response(JSON.stringify(envelope), {
|
|
39
|
+
status: init?.status ?? 200,
|
|
40
|
+
headers: { "Content-Type": "application/json" },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a JSON error Response for HTTP API handlers.
|
|
46
|
+
* Envelope: { success: false, command: 'serve', error }
|
|
47
|
+
*/
|
|
48
|
+
export function apiError(message: string, status: number): Response {
|
|
49
|
+
return new Response(JSON.stringify({ success: false, command: "serve", error: message }), {
|
|
50
|
+
status,
|
|
51
|
+
headers: { "Content-Type": "application/json" },
|
|
52
|
+
});
|
|
53
|
+
}
|
package/src/mail/client.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { MailError } from "../errors.ts";
|
|
10
|
-
import type { MailMessage, MailPayloadMap, MailProtocolType } from "../types.ts";
|
|
10
|
+
import type { MailMessage, MailMessageType, MailPayloadMap, MailProtocolType } from "../types.ts";
|
|
11
11
|
import type { MailStore } from "./store.ts";
|
|
12
12
|
|
|
13
13
|
export interface MailClient {
|
|
@@ -42,7 +42,12 @@ export interface MailClient {
|
|
|
42
42
|
checkInject(agentName: string): string;
|
|
43
43
|
|
|
44
44
|
/** List messages with optional filters. */
|
|
45
|
-
list(filters?: {
|
|
45
|
+
list(filters?: {
|
|
46
|
+
from?: string;
|
|
47
|
+
to?: string;
|
|
48
|
+
unread?: boolean;
|
|
49
|
+
type?: MailMessageType;
|
|
50
|
+
}): MailMessage[];
|
|
46
51
|
|
|
47
52
|
/** Mark a message as read by ID. Returns whether the message was already read. */
|
|
48
53
|
markRead(id: string): { alreadyRead: boolean };
|
|
@@ -50,6 +55,9 @@ export interface MailClient {
|
|
|
50
55
|
/** Reply to a message. Returns the new message ID. */
|
|
51
56
|
reply(messageId: string, body: string, from: string): string;
|
|
52
57
|
|
|
58
|
+
/** Delete a single message by id. Returns true if a row was deleted. */
|
|
59
|
+
deleteById(id: string): boolean;
|
|
60
|
+
|
|
53
61
|
/** Close the underlying store. */
|
|
54
62
|
close(): void;
|
|
55
63
|
}
|
|
@@ -75,6 +83,7 @@ export function parsePayload<T extends MailProtocolType>(
|
|
|
75
83
|
/** Protocol types that represent structured coordination messages. */
|
|
76
84
|
const PROTOCOL_TYPES = new Set<string>([
|
|
77
85
|
"worker_done",
|
|
86
|
+
"worker_died",
|
|
78
87
|
"merge_ready",
|
|
79
88
|
"merged",
|
|
80
89
|
"merge_failed",
|
|
@@ -187,6 +196,10 @@ export function createMailClient(store: MailStore): MailClient {
|
|
|
187
196
|
return { alreadyRead: false };
|
|
188
197
|
},
|
|
189
198
|
|
|
199
|
+
deleteById(id): boolean {
|
|
200
|
+
return store.deleteById(id);
|
|
201
|
+
},
|
|
202
|
+
|
|
190
203
|
reply(messageId, body, from): string {
|
|
191
204
|
const original = store.getById(messageId);
|
|
192
205
|
if (!original) {
|
package/src/mail/store.test.ts
CHANGED
|
@@ -437,6 +437,88 @@ describe("createMailStore", () => {
|
|
|
437
437
|
expect(filtered).toHaveLength(1);
|
|
438
438
|
expect(filtered[0]?.subject).toBe("msg1");
|
|
439
439
|
});
|
|
440
|
+
|
|
441
|
+
test("filters by type", () => {
|
|
442
|
+
store.insert({
|
|
443
|
+
id: "",
|
|
444
|
+
from: "lead-a",
|
|
445
|
+
to: "coordinator",
|
|
446
|
+
subject: "merge_ready: t1",
|
|
447
|
+
body: "ready",
|
|
448
|
+
type: "merge_ready",
|
|
449
|
+
priority: "normal",
|
|
450
|
+
threadId: null,
|
|
451
|
+
});
|
|
452
|
+
store.insert({
|
|
453
|
+
id: "",
|
|
454
|
+
from: "builder-a",
|
|
455
|
+
to: "lead-a",
|
|
456
|
+
subject: "Worker done",
|
|
457
|
+
body: "done",
|
|
458
|
+
type: "worker_done",
|
|
459
|
+
priority: "normal",
|
|
460
|
+
threadId: null,
|
|
461
|
+
});
|
|
462
|
+
store.insert({
|
|
463
|
+
id: "",
|
|
464
|
+
from: "lead-a",
|
|
465
|
+
to: "coordinator",
|
|
466
|
+
subject: "status",
|
|
467
|
+
body: "still going",
|
|
468
|
+
type: "status",
|
|
469
|
+
priority: "normal",
|
|
470
|
+
threadId: null,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const mr = store.getAll({ type: "merge_ready" });
|
|
474
|
+
expect(mr).toHaveLength(1);
|
|
475
|
+
expect(mr[0]?.subject).toBe("merge_ready: t1");
|
|
476
|
+
|
|
477
|
+
const wd = store.getAll({ type: "worker_done" });
|
|
478
|
+
expect(wd).toHaveLength(1);
|
|
479
|
+
expect(wd[0]?.subject).toBe("Worker done");
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("combines type with from filter", () => {
|
|
483
|
+
store.insert({
|
|
484
|
+
id: "",
|
|
485
|
+
from: "lead-a",
|
|
486
|
+
to: "coordinator",
|
|
487
|
+
subject: "merge_ready: t1",
|
|
488
|
+
body: "ready",
|
|
489
|
+
type: "merge_ready",
|
|
490
|
+
priority: "normal",
|
|
491
|
+
threadId: null,
|
|
492
|
+
});
|
|
493
|
+
store.insert({
|
|
494
|
+
id: "",
|
|
495
|
+
from: "lead-b",
|
|
496
|
+
to: "coordinator",
|
|
497
|
+
subject: "merge_ready: t2",
|
|
498
|
+
body: "ready",
|
|
499
|
+
type: "merge_ready",
|
|
500
|
+
priority: "normal",
|
|
501
|
+
threadId: null,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const mine = store.getAll({ from: "lead-a", type: "merge_ready" });
|
|
505
|
+
expect(mine).toHaveLength(1);
|
|
506
|
+
expect(mine[0]?.from).toBe("lead-a");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("returns empty array when no rows match the type filter", () => {
|
|
510
|
+
store.insert({
|
|
511
|
+
id: "",
|
|
512
|
+
from: "agent-a",
|
|
513
|
+
to: "orchestrator",
|
|
514
|
+
subject: "msg",
|
|
515
|
+
body: "body",
|
|
516
|
+
type: "status",
|
|
517
|
+
priority: "normal",
|
|
518
|
+
threadId: null,
|
|
519
|
+
});
|
|
520
|
+
expect(store.getAll({ type: "merge_ready" })).toHaveLength(0);
|
|
521
|
+
});
|
|
440
522
|
});
|
|
441
523
|
|
|
442
524
|
describe("getByThread", () => {
|
package/src/mail/store.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { Database } from "bun:sqlite";
|
|
10
10
|
import { MailError } from "../errors.ts";
|
|
11
|
-
import type { MailMessage } from "../types.ts";
|
|
11
|
+
import type { MailMessage, MailMessageType } from "../types.ts";
|
|
12
12
|
import { MAIL_MESSAGE_TYPES } from "../types.ts";
|
|
13
13
|
|
|
14
14
|
export interface MailStore {
|
|
@@ -16,10 +16,18 @@ export interface MailStore {
|
|
|
16
16
|
message: Omit<MailMessage, "read" | "createdAt" | "payload"> & { payload?: string | null },
|
|
17
17
|
): MailMessage;
|
|
18
18
|
getUnread(agentName: string): MailMessage[];
|
|
19
|
-
getAll(filters?: {
|
|
19
|
+
getAll(filters?: {
|
|
20
|
+
from?: string;
|
|
21
|
+
to?: string;
|
|
22
|
+
unread?: boolean;
|
|
23
|
+
type?: MailMessageType;
|
|
24
|
+
limit?: number;
|
|
25
|
+
}): MailMessage[];
|
|
20
26
|
getById(id: string): MailMessage | null;
|
|
21
27
|
getByThread(threadId: string): MailMessage[];
|
|
22
28
|
markRead(id: string): void;
|
|
29
|
+
/** Delete a single message by id. Returns true if a row was deleted. */
|
|
30
|
+
deleteById(id: string): boolean;
|
|
23
31
|
/** Delete messages matching the given criteria. Returns the number of messages deleted. */
|
|
24
32
|
purge(options: { all?: boolean; olderThanMs?: number; agent?: string }): number;
|
|
25
33
|
close(): void;
|
|
@@ -84,14 +92,21 @@ function migrateSchema(db: Database): void {
|
|
|
84
92
|
const hasPayloadColumn = row.sql.includes("payload");
|
|
85
93
|
const hasProtocolTypes = row.sql.includes("worker_done");
|
|
86
94
|
const hasDecisionGate = row.sql.includes("decision_gate");
|
|
95
|
+
const hasWorkerDied = row.sql.includes("worker_died");
|
|
87
96
|
|
|
88
97
|
// If schema is fully up to date, nothing to do
|
|
89
|
-
if (
|
|
98
|
+
if (
|
|
99
|
+
hasCheckConstraints &&
|
|
100
|
+
hasPayloadColumn &&
|
|
101
|
+
hasProtocolTypes &&
|
|
102
|
+
hasDecisionGate &&
|
|
103
|
+
hasWorkerDied
|
|
104
|
+
) {
|
|
90
105
|
return;
|
|
91
106
|
}
|
|
92
107
|
|
|
93
108
|
// If only missing the payload column (has correct CHECK constraints), use ALTER TABLE
|
|
94
|
-
if (hasCheckConstraints && hasProtocolTypes && !hasPayloadColumn) {
|
|
109
|
+
if (hasCheckConstraints && hasProtocolTypes && hasWorkerDied && !hasPayloadColumn) {
|
|
95
110
|
db.exec("ALTER TABLE messages ADD COLUMN payload TEXT");
|
|
96
111
|
return;
|
|
97
112
|
}
|
|
@@ -232,11 +247,16 @@ export function createMailStore(dbPath: string): MailStore {
|
|
|
232
247
|
UPDATE messages SET read = 1 WHERE id = $id
|
|
233
248
|
`);
|
|
234
249
|
|
|
250
|
+
const deleteByIdStmt = db.prepare<void, { $id: string }>(`
|
|
251
|
+
DELETE FROM messages WHERE id = $id
|
|
252
|
+
`);
|
|
253
|
+
|
|
235
254
|
// Dynamic filter queries are built at call time since the WHERE clause varies
|
|
236
255
|
function buildFilterQuery(filters?: {
|
|
237
256
|
from?: string;
|
|
238
257
|
to?: string;
|
|
239
258
|
unread?: boolean;
|
|
259
|
+
type?: MailMessageType;
|
|
240
260
|
limit?: number;
|
|
241
261
|
}): MailMessage[] {
|
|
242
262
|
const conditions: string[] = [];
|
|
@@ -254,6 +274,10 @@ export function createMailStore(dbPath: string): MailStore {
|
|
|
254
274
|
conditions.push("read = $read");
|
|
255
275
|
params.$read = filters.unread ? 0 : 1;
|
|
256
276
|
}
|
|
277
|
+
if (filters?.type !== undefined) {
|
|
278
|
+
conditions.push("type = $type");
|
|
279
|
+
params.$type = filters.type;
|
|
280
|
+
}
|
|
257
281
|
|
|
258
282
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
259
283
|
const limitClause = filters?.limit !== undefined ? ` LIMIT $limit` : "";
|
|
@@ -315,6 +339,7 @@ export function createMailStore(dbPath: string): MailStore {
|
|
|
315
339
|
from?: string;
|
|
316
340
|
to?: string;
|
|
317
341
|
unread?: boolean;
|
|
342
|
+
type?: MailMessageType;
|
|
318
343
|
limit?: number;
|
|
319
344
|
}): MailMessage[] {
|
|
320
345
|
return buildFilterQuery(filters);
|
|
@@ -334,6 +359,18 @@ export function createMailStore(dbPath: string): MailStore {
|
|
|
334
359
|
markReadStmt.run({ $id: id });
|
|
335
360
|
},
|
|
336
361
|
|
|
362
|
+
deleteById(id: string): boolean {
|
|
363
|
+
try {
|
|
364
|
+
const result = deleteByIdStmt.run({ $id: id });
|
|
365
|
+
return result.changes > 0;
|
|
366
|
+
} catch (err) {
|
|
367
|
+
throw new MailError(`Failed to delete message: ${id}`, {
|
|
368
|
+
messageId: id,
|
|
369
|
+
cause: err instanceof Error ? err : undefined,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
|
|
337
374
|
purge(options: { all?: boolean; olderThanMs?: number; agent?: string }): number {
|
|
338
375
|
// Count matching rows before deletion so we can report accurate numbers
|
|
339
376
|
if (options.all) {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { MergeError } from "../errors.ts";
|
|
7
|
+
import { acquireMergeLock, mergeLockPath, sanitizeBranchForFilename } from "./lock.ts";
|
|
8
|
+
|
|
9
|
+
describe("sanitizeBranchForFilename", () => {
|
|
10
|
+
test("replaces forward slashes with dashes", () => {
|
|
11
|
+
expect(sanitizeBranchForFilename("feature/foo")).toBe("feature-foo");
|
|
12
|
+
expect(sanitizeBranchForFilename("a/b/c")).toBe("a-b-c");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("replaces backslashes and colons", () => {
|
|
16
|
+
expect(sanitizeBranchForFilename("feature\\bar")).toBe("feature-bar");
|
|
17
|
+
expect(sanitizeBranchForFilename("ns:branch")).toBe("ns-branch");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("leaves simple branch names alone", () => {
|
|
21
|
+
expect(sanitizeBranchForFilename("main")).toBe("main");
|
|
22
|
+
expect(sanitizeBranchForFilename("develop_2")).toBe("develop_2");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("mergeLockPath", () => {
|
|
27
|
+
test("composes path under .overstory/ with sanitized branch", () => {
|
|
28
|
+
expect(mergeLockPath("/tmp/.overstory", "feature/x")).toBe(
|
|
29
|
+
"/tmp/.overstory/merge-feature-x.lock",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("acquireMergeLock", () => {
|
|
35
|
+
let overstoryDir: string;
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
overstoryDir = await mkdtemp(join(tmpdir(), "ov-merge-lock-"));
|
|
39
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
await rm(overstoryDir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("creates a lock file and returns a handle that removes it on release", () => {
|
|
47
|
+
const handle = acquireMergeLock(overstoryDir, "main");
|
|
48
|
+
expect(existsSync(handle.path)).toBe(true);
|
|
49
|
+
|
|
50
|
+
const payload = JSON.parse(readFileSync(handle.path, "utf8"));
|
|
51
|
+
expect(payload.pid).toBe(process.pid);
|
|
52
|
+
expect(payload.targetBranch).toBe("main");
|
|
53
|
+
expect(typeof payload.acquiredAt).toBe("string");
|
|
54
|
+
|
|
55
|
+
handle.release();
|
|
56
|
+
expect(existsSync(handle.path)).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("release() is idempotent", () => {
|
|
60
|
+
const handle = acquireMergeLock(overstoryDir, "main");
|
|
61
|
+
handle.release();
|
|
62
|
+
handle.release(); // should not throw
|
|
63
|
+
expect(existsSync(handle.path)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("throws MergeError when lock is held by a live process", () => {
|
|
67
|
+
// Use this test process's own PID — it is guaranteed live.
|
|
68
|
+
const path = mergeLockPath(overstoryDir, "main");
|
|
69
|
+
writeFileSync(
|
|
70
|
+
path,
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
pid: process.pid,
|
|
73
|
+
acquiredAt: new Date().toISOString(),
|
|
74
|
+
targetBranch: "main",
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
acquireMergeLock(overstoryDir, "main");
|
|
80
|
+
expect(true).toBe(false); // should not reach
|
|
81
|
+
} catch (err: unknown) {
|
|
82
|
+
expect(err).toBeInstanceOf(MergeError);
|
|
83
|
+
const msg = (err as MergeError).message;
|
|
84
|
+
expect(msg).toContain("Another ov merge is already running");
|
|
85
|
+
expect(msg).toContain(`pid ${process.pid}`);
|
|
86
|
+
expect(msg).toContain("main");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Lock file is still on disk — we did not steal it.
|
|
90
|
+
expect(existsSync(path)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("steals a stale lock whose PID is not alive", () => {
|
|
94
|
+
const path = mergeLockPath(overstoryDir, "main");
|
|
95
|
+
// PID 2147483647 is INT_MAX — extremely unlikely to be in use.
|
|
96
|
+
writeFileSync(
|
|
97
|
+
path,
|
|
98
|
+
JSON.stringify({
|
|
99
|
+
pid: 2147483647,
|
|
100
|
+
acquiredAt: new Date(Date.now() - 60_000).toISOString(),
|
|
101
|
+
targetBranch: "main",
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const handle = acquireMergeLock(overstoryDir, "main");
|
|
106
|
+
const payload = JSON.parse(readFileSync(handle.path, "utf8"));
|
|
107
|
+
expect(payload.pid).toBe(process.pid);
|
|
108
|
+
handle.release();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("steals an unparseable lock file", () => {
|
|
112
|
+
const path = mergeLockPath(overstoryDir, "main");
|
|
113
|
+
writeFileSync(path, "not json");
|
|
114
|
+
|
|
115
|
+
const handle = acquireMergeLock(overstoryDir, "main");
|
|
116
|
+
const payload = JSON.parse(readFileSync(handle.path, "utf8"));
|
|
117
|
+
expect(payload.pid).toBe(process.pid);
|
|
118
|
+
handle.release();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("locks on different target branches are independent", () => {
|
|
122
|
+
const a = acquireMergeLock(overstoryDir, "main");
|
|
123
|
+
const b = acquireMergeLock(overstoryDir, "develop");
|
|
124
|
+
expect(existsSync(a.path)).toBe(true);
|
|
125
|
+
expect(existsSync(b.path)).toBe(true);
|
|
126
|
+
expect(a.path).not.toBe(b.path);
|
|
127
|
+
a.release();
|
|
128
|
+
b.release();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("error message includes path so operator can manually clear", () => {
|
|
132
|
+
const path = mergeLockPath(overstoryDir, "main");
|
|
133
|
+
writeFileSync(
|
|
134
|
+
path,
|
|
135
|
+
JSON.stringify({
|
|
136
|
+
pid: process.pid,
|
|
137
|
+
acquiredAt: new Date().toISOString(),
|
|
138
|
+
targetBranch: "main",
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
acquireMergeLock(overstoryDir, "main");
|
|
144
|
+
expect(true).toBe(false);
|
|
145
|
+
} catch (err: unknown) {
|
|
146
|
+
expect((err as MergeError).message).toContain(path);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel-file lock to prevent concurrent `ov merge` runs against the same
|
|
3
|
+
* canonical (target) branch.
|
|
4
|
+
*
|
|
5
|
+
* Two parallel merges into the same canonical branch can produce a misleading
|
|
6
|
+
* transient view: one merge runs the git operations while the second observes
|
|
7
|
+
* conflict markers mid-merge and reports a false failure. See seeds issue
|
|
8
|
+
* overstory-9610 for the original incident.
|
|
9
|
+
*
|
|
10
|
+
* The lock is a single JSON file at `.overstory/merge-{sanitized-target}.lock`
|
|
11
|
+
* created atomically with `writeFileSync(..., { flag: "wx" })`. If the file
|
|
12
|
+
* already exists, the holder PID is checked: live → fail fast, dead → take
|
|
13
|
+
* over. Released on exit via the returned handle.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { MergeError } from "../errors.ts";
|
|
19
|
+
import { isProcessAlive } from "../worktree/tmux.ts";
|
|
20
|
+
|
|
21
|
+
export interface MergeLockHandle {
|
|
22
|
+
/** Path to the lock file on disk (useful for diagnostics / tests). */
|
|
23
|
+
readonly path: string;
|
|
24
|
+
/** Release the lock. Idempotent — safe to call multiple times. */
|
|
25
|
+
release(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface LockPayload {
|
|
29
|
+
pid: number;
|
|
30
|
+
acquiredAt: string;
|
|
31
|
+
targetBranch: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Sanitize a branch name for use in a filename.
|
|
36
|
+
* Replaces "/", "\\", and ":" with "-" so `feature/foo` becomes `feature-foo`.
|
|
37
|
+
*/
|
|
38
|
+
export function sanitizeBranchForFilename(branch: string): string {
|
|
39
|
+
return branch.replace(/[/\\:]/g, "-");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Compute the lock file path for a given target branch. */
|
|
43
|
+
export function mergeLockPath(overstoryDir: string, targetBranch: string): string {
|
|
44
|
+
return join(overstoryDir, `merge-${sanitizeBranchForFilename(targetBranch)}.lock`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Acquire the merge lock for a given target branch. Throws `MergeError` if
|
|
49
|
+
* another live `ov merge` is already running against this target. Stale locks
|
|
50
|
+
* (PID no longer alive) are taken over automatically.
|
|
51
|
+
*
|
|
52
|
+
* The caller MUST call `release()` on the returned handle when done.
|
|
53
|
+
*/
|
|
54
|
+
export function acquireMergeLock(overstoryDir: string, targetBranch: string): MergeLockHandle {
|
|
55
|
+
const path = mergeLockPath(overstoryDir, targetBranch);
|
|
56
|
+
const payload: LockPayload = {
|
|
57
|
+
pid: process.pid,
|
|
58
|
+
acquiredAt: new Date().toISOString(),
|
|
59
|
+
targetBranch,
|
|
60
|
+
};
|
|
61
|
+
const serialized = JSON.stringify(payload);
|
|
62
|
+
|
|
63
|
+
const tryCreate = (): boolean => {
|
|
64
|
+
try {
|
|
65
|
+
writeFileSync(path, serialized, { flag: "wx" });
|
|
66
|
+
return true;
|
|
67
|
+
} catch (err: unknown) {
|
|
68
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
69
|
+
if (code === "EEXIST") return false;
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (tryCreate()) {
|
|
75
|
+
return makeHandle(path);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Lock file exists. Inspect the holder before failing.
|
|
79
|
+
const existing = readLockPayload(path);
|
|
80
|
+
const holderPid = existing?.pid;
|
|
81
|
+
const holderAlive = typeof holderPid === "number" && isProcessAlive(holderPid);
|
|
82
|
+
|
|
83
|
+
if (holderAlive) {
|
|
84
|
+
const since = existing?.acquiredAt ?? "unknown time";
|
|
85
|
+
throw new MergeError(
|
|
86
|
+
`Another ov merge is already running for "${targetBranch}" (pid ${holderPid}, acquired ${since}). Wait for it to finish, or remove ${path} if you are sure it is stale.`,
|
|
87
|
+
{ branchName: targetBranch },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Stale or unparseable lock — remove and retry once. If a third process
|
|
92
|
+
// won the race in between, surface that as a clear retry-soon error.
|
|
93
|
+
try {
|
|
94
|
+
unlinkSync(path);
|
|
95
|
+
} catch {
|
|
96
|
+
// File may have just been removed by another cleanup — fine.
|
|
97
|
+
}
|
|
98
|
+
if (tryCreate()) {
|
|
99
|
+
return makeHandle(path);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
throw new MergeError(
|
|
103
|
+
`Another ov merge raced to acquire the lock for "${targetBranch}". Retry shortly.`,
|
|
104
|
+
{ branchName: targetBranch },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readLockPayload(path: string): LockPayload | null {
|
|
109
|
+
try {
|
|
110
|
+
const content = readFileSync(path, "utf8");
|
|
111
|
+
const parsed = JSON.parse(content) as unknown;
|
|
112
|
+
if (
|
|
113
|
+
parsed !== null &&
|
|
114
|
+
typeof parsed === "object" &&
|
|
115
|
+
"pid" in parsed &&
|
|
116
|
+
typeof (parsed as { pid: unknown }).pid === "number"
|
|
117
|
+
) {
|
|
118
|
+
return parsed as LockPayload;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function makeHandle(path: string): MergeLockHandle {
|
|
127
|
+
let released = false;
|
|
128
|
+
return {
|
|
129
|
+
path,
|
|
130
|
+
release(): void {
|
|
131
|
+
if (released) return;
|
|
132
|
+
released = true;
|
|
133
|
+
try {
|
|
134
|
+
unlinkSync(path);
|
|
135
|
+
} catch {
|
|
136
|
+
// File may already be gone — not an error.
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|