tmux-ide 1.2.1 → 2.0.0
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/bin/cli.js +4 -216
- package/bin/cli.ts +536 -0
- package/package.json +33 -14
- package/src/__tests__/support.ts +186 -0
- package/src/{attach.js → attach.ts} +7 -4
- package/src/cli.test.ts +314 -0
- package/src/command-center/discovery.test.ts +265 -0
- package/src/command-center/discovery.ts +296 -0
- package/src/command-center/index.ts +31 -0
- package/src/command-center/schemas.ts +57 -0
- package/src/command-center/server.test.ts +818 -0
- package/src/command-center/server.ts +1426 -0
- package/src/config-cli.test.ts +86 -0
- package/src/config.test.ts +205 -0
- package/src/config.ts +313 -0
- package/src/detect.test.ts +142 -0
- package/src/{detect.js → detect.ts} +31 -15
- package/src/dispatch.ts +102 -0
- package/src/doctor.test.ts +119 -0
- package/src/doctor.ts +167 -0
- package/src/init.test.ts +101 -0
- package/src/init.ts +144 -0
- package/src/inspect.test.ts +67 -0
- package/src/{inspect.js → inspect.ts} +95 -10
- package/src/integration.test.ts.skip +276 -0
- package/src/js-yaml.d.ts +10 -0
- package/src/launch.test.ts +222 -0
- package/src/launch.ts +494 -0
- package/src/lib/auth/auth-service.test.ts +178 -0
- package/src/lib/auth/auth-service.ts +227 -0
- package/src/lib/auth/middleware.test.ts +119 -0
- package/src/lib/auth/middleware.ts +56 -0
- package/src/lib/auth/types.ts +13 -0
- package/src/lib/authorship.test.ts +242 -0
- package/src/lib/authorship.ts +280 -0
- package/src/lib/cast/recorder.test.ts +127 -0
- package/src/lib/cast/recorder.ts +148 -0
- package/src/lib/daemon-watchdog.ts +102 -0
- package/src/lib/daemon.ts +417 -0
- package/src/lib/dot-path.test.ts +66 -0
- package/src/lib/dot-path.ts +17 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/event-log.test.ts +189 -0
- package/src/lib/event-log.ts +237 -0
- package/src/lib/github-pr.ts +182 -0
- package/src/lib/hq/client.test.ts +154 -0
- package/src/lib/hq/client.ts +142 -0
- package/src/lib/hq/mdns.test.ts +54 -0
- package/src/lib/hq/mdns.ts +88 -0
- package/src/lib/hq/registry.test.ts +237 -0
- package/src/lib/hq/registry.ts +145 -0
- package/src/lib/hq/types.ts +44 -0
- package/src/lib/ipc/socket-protocol.test.ts +126 -0
- package/src/lib/ipc/socket-protocol.ts +324 -0
- package/src/lib/launch-plan.test.ts +119 -0
- package/src/lib/launch-plan.ts +90 -0
- package/src/lib/log.test.ts +39 -0
- package/src/lib/log.ts +71 -0
- package/src/lib/metrics.test.ts +213 -0
- package/src/lib/metrics.ts +345 -0
- package/src/lib/orchestrator.test.ts +2358 -0
- package/src/lib/orchestrator.ts +1792 -0
- package/src/lib/{output.js → output.ts} +18 -10
- package/src/lib/plan-store.test.ts +148 -0
- package/src/lib/plan-store.ts +131 -0
- package/src/lib/research.test.ts +208 -0
- package/src/lib/research.ts +475 -0
- package/src/lib/round-trip.test.ts +341 -0
- package/src/lib/session-monitor.test.ts +152 -0
- package/src/lib/session-monitor.ts +282 -0
- package/src/lib/session-options.test.ts +146 -0
- package/src/lib/{session-options.js → session-options.ts} +11 -6
- package/src/lib/shell.test.ts +20 -0
- package/src/lib/shell.ts +8 -0
- package/src/lib/sizes.test.ts +73 -0
- package/src/lib/{sizes.js → sizes.ts} +4 -4
- package/src/lib/skill-registry.test.ts +134 -0
- package/src/lib/skill-registry.ts +86 -0
- package/src/lib/slugify.test.ts +35 -0
- package/src/lib/slugify.ts +10 -0
- package/src/lib/task-store.ts +352 -0
- package/src/lib/tmux.test.ts +500 -0
- package/src/lib/tmux.ts +372 -0
- package/src/lib/token-tracker.test.ts +102 -0
- package/src/lib/token-tracker.ts +70 -0
- package/src/lib/tunnels/cloudflare.test.ts +73 -0
- package/src/lib/tunnels/cloudflare.ts +254 -0
- package/src/lib/tunnels/manager.test.ts +63 -0
- package/src/lib/tunnels/manager.ts +132 -0
- package/src/lib/tunnels/ngrok.test.ts +94 -0
- package/src/lib/tunnels/ngrok.ts +261 -0
- package/src/lib/tunnels/tailscale.test.ts +103 -0
- package/src/lib/tunnels/tailscale.ts +1048 -0
- package/src/lib/tunnels/types.ts +44 -0
- package/src/lib/validation.test.ts +289 -0
- package/src/lib/validation.ts +123 -0
- package/src/lib/webhook.ts +51 -0
- package/src/lib/workflow-store.ts +209 -0
- package/src/lib/ws-v3/protocol.test.ts +127 -0
- package/src/lib/ws-v3/protocol.ts +160 -0
- package/src/lib/yaml-io.test.ts +125 -0
- package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
- package/src/ls.test.ts +76 -0
- package/src/{ls.js → ls.ts} +4 -4
- package/src/metrics-cli.ts +177 -0
- package/src/notify.ts +85 -0
- package/src/orchestrator-status.ts +228 -0
- package/src/plan.ts +117 -0
- package/src/postinstall.test.ts +79 -0
- package/src/remote.test.ts +115 -0
- package/src/remote.ts +148 -0
- package/src/restart.ts +24 -0
- package/src/schemas/domain.ts +422 -0
- package/src/schemas/ide-config.ts +148 -0
- package/src/schemas/index.ts +53 -0
- package/src/send.test.ts +70 -0
- package/src/send.ts +181 -0
- package/src/skill.test.ts +114 -0
- package/src/skill.ts +138 -0
- package/src/status.ts +66 -0
- package/src/stop.test.ts +176 -0
- package/src/stop.ts +47 -0
- package/src/task.test.ts +1466 -0
- package/src/task.ts +1143 -0
- package/src/tunnel.test.ts +80 -0
- package/src/tunnel.ts +133 -0
- package/src/types.ts +15 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/terminal/index.ts +9 -0
- package/src/ui/types.ts +91 -0
- package/src/ui/web/base.css +80 -0
- package/src/ui/web/components/Box.tsx +59 -0
- package/src/ui/web/components/Input.tsx +32 -0
- package/src/ui/web/components/ScrollBox.tsx +60 -0
- package/src/ui/web/components/Text.tsx +28 -0
- package/src/ui/web/hooks.ts +106 -0
- package/src/ui/web/index.ts +27 -0
- package/src/ui/web/render.ts +77 -0
- package/src/ui/web/utils/color.test.ts +93 -0
- package/src/ui/web/utils/color.ts +27 -0
- package/src/validate.test.ts +385 -0
- package/src/validate.ts +217 -0
- package/src/widgets/changes/index.tsx +576 -0
- package/src/widgets/config/index.tsx +440 -0
- package/src/widgets/costs/index.tsx +216 -0
- package/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/src/widgets/explorer/footer.tsx +20 -0
- package/src/widgets/explorer/header.tsx +23 -0
- package/src/widgets/explorer/index.tsx +408 -0
- package/src/widgets/explorer/tree-model.test.ts +214 -0
- package/src/widgets/explorer/tree-model.ts +103 -0
- package/src/widgets/explorer/tree.tsx +156 -0
- package/src/widgets/lib/config-model.ts +116 -0
- package/src/widgets/lib/files.test.ts +103 -0
- package/src/widgets/lib/files.ts +88 -0
- package/src/widgets/lib/git.test.ts +151 -0
- package/src/widgets/lib/git.ts +88 -0
- package/src/widgets/lib/pane-comms.test.ts +178 -0
- package/src/widgets/lib/pane-comms.ts +192 -0
- package/src/widgets/lib/theme.ts +151 -0
- package/src/widgets/lib/watcher.ts +50 -0
- package/src/widgets/mission-control/activity-feed.tsx +59 -0
- package/src/widgets/mission-control/agent-panel.tsx +132 -0
- package/src/widgets/mission-control/command-bar.tsx +63 -0
- package/src/widgets/mission-control/index.tsx +751 -0
- package/src/widgets/mission-control/task-panel.tsx +238 -0
- package/src/widgets/preview/index.tsx +415 -0
- package/src/widgets/resolve.ts +40 -0
- package/src/widgets/setup/agent-naming.tsx +112 -0
- package/src/widgets/setup/config-tree.tsx +238 -0
- package/src/widgets/setup/detect-panel.tsx +72 -0
- package/src/widgets/setup/field-editor.tsx +265 -0
- package/src/widgets/setup/footer.tsx +107 -0
- package/src/widgets/setup/index.tsx +340 -0
- package/src/widgets/setup/layout-picker.tsx +96 -0
- package/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/src/widgets/setup/review-panel.tsx +140 -0
- package/src/widgets/setup/setup-model.test.ts +303 -0
- package/src/widgets/setup/setup-model.ts +188 -0
- package/src/widgets/tasks/index.tsx +129 -0
- package/src/widgets/tasks/task-detail.tsx +309 -0
- package/src/widgets/tasks/task-form.tsx +193 -0
- package/src/widgets/tasks/task-list.tsx +205 -0
- package/src/widgets/tasks/task-model.test.ts +473 -0
- package/src/widgets/tasks/task-model.ts +157 -0
- package/templates/AGENTS.md +20 -0
- package/templates/missions.yml +41 -0
- package/templates/skills/backend.md +30 -0
- package/templates/skills/frontend.md +30 -0
- package/templates/skills/general-worker.md +30 -0
- package/templates/skills/researcher.md +69 -0
- package/templates/skills/reviewer.md +30 -0
- package/src/config.js +0 -341
- package/src/doctor.js +0 -91
- package/src/init.js +0 -73
- package/src/launch.js +0 -245
- package/src/lib/dot-path.js +0 -16
- package/src/lib/errors.js +0 -35
- package/src/lib/launch-plan.js +0 -49
- package/src/lib/session-monitor.js +0 -179
- package/src/lib/tmux.js +0 -237
- package/src/restart.js +0 -16
- package/src/status.js +0 -35
- package/src/stop.js +0 -25
- package/src/validate.js +0 -154
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach, expect } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, statSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { appendEvent, readEvents, type OrchestratorEvent } from "./event-log.ts";
|
|
6
|
+
|
|
7
|
+
let tmpDir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-evlog-test-"));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("appendEvent", () => {
|
|
18
|
+
it("creates .tasks/ and events.log if they don't exist", () => {
|
|
19
|
+
appendEvent(tmpDir, {
|
|
20
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
21
|
+
type: "dispatch",
|
|
22
|
+
taskId: "001",
|
|
23
|
+
agent: "Agent 1",
|
|
24
|
+
message: "Dispatched task 001 to Agent 1",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(existsSync(join(tmpDir, ".tasks", "events.log"))).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("appends events as JSON lines", () => {
|
|
31
|
+
appendEvent(tmpDir, {
|
|
32
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
33
|
+
type: "dispatch",
|
|
34
|
+
taskId: "001",
|
|
35
|
+
message: "first",
|
|
36
|
+
});
|
|
37
|
+
appendEvent(tmpDir, {
|
|
38
|
+
timestamp: "2026-01-01T00:01:00Z",
|
|
39
|
+
type: "completion",
|
|
40
|
+
taskId: "001",
|
|
41
|
+
message: "second",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const events = readEvents(tmpDir);
|
|
45
|
+
expect(events.length).toBe(2);
|
|
46
|
+
expect(events[0]!.type).toBe("dispatch");
|
|
47
|
+
expect(events[1]!.type).toBe("completion");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("works without optional fields", () => {
|
|
51
|
+
appendEvent(tmpDir, {
|
|
52
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
53
|
+
type: "error",
|
|
54
|
+
message: "something broke",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const events = readEvents(tmpDir);
|
|
58
|
+
expect(events.length).toBe(1);
|
|
59
|
+
expect(events[0]!.taskId).toBe(undefined);
|
|
60
|
+
expect(events[0]!.agent).toBe(undefined);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("readEvents", () => {
|
|
65
|
+
it("returns empty array when log doesn't exist", () => {
|
|
66
|
+
expect(readEvents(tmpDir)).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns all event types", () => {
|
|
70
|
+
const types: Array<OrchestratorEvent["type"]> = [
|
|
71
|
+
"dispatch",
|
|
72
|
+
"stall",
|
|
73
|
+
"completion",
|
|
74
|
+
"retry",
|
|
75
|
+
"reconcile",
|
|
76
|
+
"error",
|
|
77
|
+
];
|
|
78
|
+
for (const type of types) {
|
|
79
|
+
appendEvent(tmpDir, { timestamp: "2026-01-01T00:00:00Z", type, message: type });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const events = readEvents(tmpDir);
|
|
83
|
+
expect(events.length).toBe(6);
|
|
84
|
+
expect(events.map((e) => e.type)).toEqual(types);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("skips corrupted lines and returns valid events", () => {
|
|
88
|
+
const tasksDir = join(tmpDir, ".tasks");
|
|
89
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
90
|
+
const logPath = join(tasksDir, "events.log");
|
|
91
|
+
const validEvent = JSON.stringify({
|
|
92
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
93
|
+
type: "dispatch",
|
|
94
|
+
message: "valid event",
|
|
95
|
+
});
|
|
96
|
+
// Write: valid line, corrupted line, another valid line
|
|
97
|
+
writeFileSync(logPath, `${validEvent}\nnot json at all\n${validEvent}\n`);
|
|
98
|
+
|
|
99
|
+
const events = readEvents(tmpDir);
|
|
100
|
+
expect(events.length).toBe(2);
|
|
101
|
+
expect(events[0]!.message).toBe("valid event");
|
|
102
|
+
expect(events[1]!.message).toBe("valid event");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("reads events from both current and rotated log files", () => {
|
|
106
|
+
const tasksDir = join(tmpDir, ".tasks");
|
|
107
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
108
|
+
|
|
109
|
+
const oldEvent = JSON.stringify({
|
|
110
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
111
|
+
type: "dispatch",
|
|
112
|
+
message: "old event",
|
|
113
|
+
});
|
|
114
|
+
const newEvent = JSON.stringify({
|
|
115
|
+
timestamp: "2026-01-02T00:00:00Z",
|
|
116
|
+
type: "completion",
|
|
117
|
+
message: "new event",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
writeFileSync(join(tasksDir, "events.log.1"), oldEvent + "\n");
|
|
121
|
+
writeFileSync(join(tasksDir, "events.log"), newEvent + "\n");
|
|
122
|
+
|
|
123
|
+
const events = readEvents(tmpDir);
|
|
124
|
+
expect(events.length).toBe(2);
|
|
125
|
+
expect(events[0]!.message).toBe("old event");
|
|
126
|
+
expect(events[1]!.message).toBe("new event");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("log rotation", () => {
|
|
131
|
+
it("rotates events.log when it exceeds 1MB", () => {
|
|
132
|
+
const tasksDir = join(tmpDir, ".tasks");
|
|
133
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
134
|
+
const logPath = join(tasksDir, "events.log");
|
|
135
|
+
|
|
136
|
+
// Write a file just over 1MB directly
|
|
137
|
+
const event = JSON.stringify({
|
|
138
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
139
|
+
type: "dispatch",
|
|
140
|
+
taskId: "001",
|
|
141
|
+
message: "x".repeat(200),
|
|
142
|
+
});
|
|
143
|
+
const lineSize = Buffer.byteLength(event + "\n");
|
|
144
|
+
const linesNeeded = Math.ceil(1048577 / lineSize);
|
|
145
|
+
writeFileSync(logPath, (event + "\n").repeat(linesNeeded));
|
|
146
|
+
|
|
147
|
+
expect(statSync(logPath).size > 1048576).toBeTruthy();
|
|
148
|
+
|
|
149
|
+
// Next appendEvent should trigger rotation
|
|
150
|
+
appendEvent(tmpDir, {
|
|
151
|
+
timestamp: "2026-01-02T00:00:00Z",
|
|
152
|
+
type: "completion",
|
|
153
|
+
taskId: "002",
|
|
154
|
+
message: "trigger rotation",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// events.log should now be small (just the new event)
|
|
158
|
+
expect(statSync(logPath).size < 1024).toBeTruthy();
|
|
159
|
+
|
|
160
|
+
// Rotated file should exist with the old content
|
|
161
|
+
const rotatedPath = join(tasksDir, "events.log.1");
|
|
162
|
+
expect(existsSync(rotatedPath)).toBeTruthy();
|
|
163
|
+
expect(statSync(rotatedPath).size > 1048576).toBeTruthy();
|
|
164
|
+
|
|
165
|
+
// readEvents should return events from both files
|
|
166
|
+
const events = readEvents(tmpDir);
|
|
167
|
+
expect(events.length > linesNeeded).toBeTruthy();
|
|
168
|
+
expect(events[events.length - 1]!.message).toBe("trigger rotation");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("does not rotate when file is under 1MB", () => {
|
|
172
|
+
appendEvent(tmpDir, {
|
|
173
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
174
|
+
type: "dispatch",
|
|
175
|
+
message: "small event",
|
|
176
|
+
});
|
|
177
|
+
appendEvent(tmpDir, {
|
|
178
|
+
timestamp: "2026-01-01T00:01:00Z",
|
|
179
|
+
type: "completion",
|
|
180
|
+
message: "another small event",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const rotatedPath = join(tmpDir, ".tasks", "events.log.1");
|
|
184
|
+
expect(!existsSync(rotatedPath)).toBeTruthy();
|
|
185
|
+
|
|
186
|
+
const events = readEvents(tmpDir);
|
|
187
|
+
expect(events.length).toBe(2);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
statSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { StructuredEventSchemaZ } from "../schemas/domain.ts";
|
|
13
|
+
import type { z } from "zod";
|
|
14
|
+
import { fireWebhooks, type WebhookConfig } from "./webhook.ts";
|
|
15
|
+
|
|
16
|
+
export type EventType =
|
|
17
|
+
| "dispatch"
|
|
18
|
+
| "stall"
|
|
19
|
+
| "completion"
|
|
20
|
+
| "retry"
|
|
21
|
+
| "reconcile"
|
|
22
|
+
| "error"
|
|
23
|
+
| "task_created"
|
|
24
|
+
| "status_change"
|
|
25
|
+
| "send"
|
|
26
|
+
| "notify"
|
|
27
|
+
| "milestone_validating"
|
|
28
|
+
| "milestone_complete"
|
|
29
|
+
| "validation_dispatch"
|
|
30
|
+
| "remediation"
|
|
31
|
+
| "validation_failed"
|
|
32
|
+
| "planning"
|
|
33
|
+
| "mission_complete"
|
|
34
|
+
| "discovered_issue"
|
|
35
|
+
| "research_dispatch"
|
|
36
|
+
| "research_finding"
|
|
37
|
+
| "agent_heartbeat"
|
|
38
|
+
| "session_start"
|
|
39
|
+
| "session_end";
|
|
40
|
+
|
|
41
|
+
export interface OrchestratorEvent {
|
|
42
|
+
timestamp: string;
|
|
43
|
+
type: EventType;
|
|
44
|
+
taskId?: string;
|
|
45
|
+
agent?: string;
|
|
46
|
+
message: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type StructuredEvent = z.infer<typeof StructuredEventSchemaZ>;
|
|
50
|
+
|
|
51
|
+
let _webhooks: WebhookConfig[] = [];
|
|
52
|
+
|
|
53
|
+
/** Configure webhook URLs for event delivery. Call once at startup. */
|
|
54
|
+
export function setWebhookConfig(webhooks: WebhookConfig[]): void {
|
|
55
|
+
_webhooks = webhooks;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Generate a human-readable message from a structured event's typed fields.
|
|
60
|
+
*/
|
|
61
|
+
export function formatEventMessage(event: StructuredEvent): string {
|
|
62
|
+
switch (event.type) {
|
|
63
|
+
case "dispatch":
|
|
64
|
+
return `Dispatched task ${event.taskId} to ${event.agent}`;
|
|
65
|
+
case "completion": {
|
|
66
|
+
const duration =
|
|
67
|
+
event.durationMs != null ? ` in ${Math.round(event.durationMs / 1000)}s` : "";
|
|
68
|
+
return `Task ${event.taskId} completed by ${event.agent}${duration}`;
|
|
69
|
+
}
|
|
70
|
+
case "stall":
|
|
71
|
+
return `Stall detected: task ${event.taskId} by ${event.agent} (${Math.floor(event.elapsedMs / 60000)}m)`;
|
|
72
|
+
case "retry":
|
|
73
|
+
return `Retry ${event.attempt}/${event.maxRetries} for task ${event.taskId}: ${event.reason}`;
|
|
74
|
+
case "reconcile":
|
|
75
|
+
return `Reconcile task ${event.taskId}: agent "${event.previousAgent}" ${event.action}`;
|
|
76
|
+
case "error": {
|
|
77
|
+
const ctx = [event.taskId && `task ${event.taskId}`, event.agent && `agent ${event.agent}`]
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.join(", ");
|
|
80
|
+
const code = event.code ? ` [${event.code}]` : "";
|
|
81
|
+
return ctx ? `Error (${ctx}): ${event.message}${code}` : `Error: ${event.message}${code}`;
|
|
82
|
+
}
|
|
83
|
+
case "task_created":
|
|
84
|
+
return `Task ${event.taskId} created: "${event.title}"`;
|
|
85
|
+
case "status_change":
|
|
86
|
+
return `Task ${event.taskId} status: ${event.from} → ${event.to}`;
|
|
87
|
+
case "send": {
|
|
88
|
+
const preview =
|
|
89
|
+
event.message.length > 50 ? event.message.slice(0, 50) + "..." : event.message;
|
|
90
|
+
return `Sent to ${event.target}: "${preview}"`;
|
|
91
|
+
}
|
|
92
|
+
case "notify": {
|
|
93
|
+
const preview =
|
|
94
|
+
event.message.length > 50 ? event.message.slice(0, 50) + "..." : event.message;
|
|
95
|
+
return `Notified ${event.target}: "${preview}"`;
|
|
96
|
+
}
|
|
97
|
+
case "milestone_validating":
|
|
98
|
+
return event.title && event.milestoneId
|
|
99
|
+
? `Milestone "${event.title}" (${event.milestoneId}) entered validation`
|
|
100
|
+
: "Milestone entered validation";
|
|
101
|
+
case "milestone_complete":
|
|
102
|
+
return event.title && event.milestoneId
|
|
103
|
+
? `Milestone "${event.title}" (${event.milestoneId}) completed`
|
|
104
|
+
: "Milestone completed";
|
|
105
|
+
case "validation_dispatch":
|
|
106
|
+
return event.title && event.target
|
|
107
|
+
? `Dispatched validation for "${event.title}" to ${event.target}`
|
|
108
|
+
: "Dispatched milestone validation";
|
|
109
|
+
case "remediation":
|
|
110
|
+
return event.assertionId
|
|
111
|
+
? `Created remediation task ${event.taskId} for assertion ${event.assertionId}`
|
|
112
|
+
: `Created remediation task ${event.taskId}`;
|
|
113
|
+
case "validation_failed":
|
|
114
|
+
return event.title && event.failedCount != null
|
|
115
|
+
? `Milestone "${event.title}" validation failed (${event.failedCount} assertion(s))`
|
|
116
|
+
: "Milestone validation failed";
|
|
117
|
+
case "planning":
|
|
118
|
+
return event.target
|
|
119
|
+
? `Dispatched mission planning to ${event.target}`
|
|
120
|
+
: "Dispatched mission planning";
|
|
121
|
+
case "mission_complete":
|
|
122
|
+
return event.title ? `Mission "${event.title}" completed` : "Mission completed";
|
|
123
|
+
case "discovered_issue":
|
|
124
|
+
return event.issue
|
|
125
|
+
? `Created follow-up task ${event.taskId}: ${event.issue}`
|
|
126
|
+
: `Created follow-up task ${event.taskId} for discovered issue`;
|
|
127
|
+
case "research_dispatch":
|
|
128
|
+
return event.target && event.researchType
|
|
129
|
+
? `Dispatched ${event.researchType} research to ${event.target}`
|
|
130
|
+
: `Dispatched research task ${event.taskId}`;
|
|
131
|
+
case "research_finding":
|
|
132
|
+
return event.summary && event.researchType
|
|
133
|
+
? `Research finding (${event.researchType}): ${event.summary}`
|
|
134
|
+
: `Recorded research finding from task ${event.taskId}`;
|
|
135
|
+
default:
|
|
136
|
+
return (
|
|
137
|
+
(event as { message?: string }).message ?? `Event: ${(event as { type: string }).type}`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const MAX_LOG_SIZE = 1048576; // 1MB
|
|
143
|
+
let _rotating = false;
|
|
144
|
+
|
|
145
|
+
export function appendEvent(dir: string, event: OrchestratorEvent | StructuredEvent): void {
|
|
146
|
+
const tasksDir = join(dir, ".tasks");
|
|
147
|
+
if (!existsSync(tasksDir)) {
|
|
148
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
149
|
+
}
|
|
150
|
+
const logPath = join(tasksDir, "events.log");
|
|
151
|
+
|
|
152
|
+
// Rotate if file exceeds 1MB (atomic: copy to temp, rename temp to .1, remove old)
|
|
153
|
+
// Guard with _rotating flag to prevent concurrent rotation from interleaved calls
|
|
154
|
+
if (!_rotating && existsSync(logPath)) {
|
|
155
|
+
try {
|
|
156
|
+
const size = statSync(logPath).size;
|
|
157
|
+
if (size > MAX_LOG_SIZE) {
|
|
158
|
+
_rotating = true;
|
|
159
|
+
const rotatedPath = logPath + ".1";
|
|
160
|
+
const tmpRotated = rotatedPath + ".tmp";
|
|
161
|
+
copyFileSync(logPath, tmpRotated);
|
|
162
|
+
renameSync(tmpRotated, rotatedPath);
|
|
163
|
+
unlinkSync(logPath);
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// stat/copy/rename failure — continue writing to current file
|
|
167
|
+
} finally {
|
|
168
|
+
_rotating = false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const line = JSON.stringify(event) + "\n";
|
|
173
|
+
|
|
174
|
+
// If it's a structured event (no message field, or error type with typed payload),
|
|
175
|
+
// validate with schema before writing
|
|
176
|
+
if (!("message" in event) || StructuredEventSchemaZ.safeParse(event).success) {
|
|
177
|
+
appendFileSync(logPath, line);
|
|
178
|
+
if (_webhooks.length > 0) fireWebhooks(_webhooks, event);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Old-format event with free-form message — write as-is
|
|
183
|
+
appendFileSync(logPath, line);
|
|
184
|
+
if (_webhooks.length > 0) fireWebhooks(_webhooks, event);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function readEvents(dir: string): OrchestratorEvent[] {
|
|
188
|
+
const logPath = join(dir, ".tasks", "events.log");
|
|
189
|
+
const rotatedPath = logPath + ".1";
|
|
190
|
+
|
|
191
|
+
// Collect lines from rotated file first (older events), then current
|
|
192
|
+
let lines: string[] = [];
|
|
193
|
+
if (existsSync(rotatedPath)) {
|
|
194
|
+
const raw = readFileSync(rotatedPath, "utf-8").trim();
|
|
195
|
+
if (raw) lines = raw.split("\n");
|
|
196
|
+
}
|
|
197
|
+
if (existsSync(logPath)) {
|
|
198
|
+
const raw = readFileSync(logPath, "utf-8").trim();
|
|
199
|
+
if (raw) lines = lines.concat(raw.split("\n"));
|
|
200
|
+
}
|
|
201
|
+
if (lines.length === 0) return [];
|
|
202
|
+
|
|
203
|
+
return lines
|
|
204
|
+
.map((line) => {
|
|
205
|
+
try {
|
|
206
|
+
const parsed = JSON.parse(line);
|
|
207
|
+
|
|
208
|
+
// Old format: has a message field → return as-is
|
|
209
|
+
if ("message" in parsed && typeof parsed.message === "string") {
|
|
210
|
+
return parsed as OrchestratorEvent;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// New structured format: try to parse and generate message
|
|
214
|
+
const result = StructuredEventSchemaZ.safeParse(parsed);
|
|
215
|
+
if (result.success) {
|
|
216
|
+
const structured = result.data;
|
|
217
|
+
return {
|
|
218
|
+
timestamp: structured.timestamp,
|
|
219
|
+
type: structured.type,
|
|
220
|
+
taskId: "taskId" in structured ? structured.taskId : undefined,
|
|
221
|
+
agent: "agent" in structured ? structured.agent : undefined,
|
|
222
|
+
message: formatEventMessage(structured),
|
|
223
|
+
} as OrchestratorEvent;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Fallback: treat as old format if it has at minimum type and timestamp
|
|
227
|
+
if (parsed.type && parsed.timestamp) {
|
|
228
|
+
return { ...parsed, message: parsed.message ?? "" } as OrchestratorEvent;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return null;
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
.filter((e): e is OrchestratorEvent => e !== null);
|
|
237
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import type { Task, Mission } from "./task-store.ts";
|
|
3
|
+
import type { ProofSchema } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
export interface PrResult {
|
|
6
|
+
url: string;
|
|
7
|
+
number: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if the gh CLI is available.
|
|
12
|
+
*/
|
|
13
|
+
export function isGhAvailable(): boolean {
|
|
14
|
+
try {
|
|
15
|
+
execFileSync("gh", ["--version"], {
|
|
16
|
+
encoding: "utf-8",
|
|
17
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
18
|
+
});
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a GitHub PR for a completed task.
|
|
27
|
+
* Returns the PR URL and number, or null if creation failed.
|
|
28
|
+
*/
|
|
29
|
+
export function createTaskPr(task: Task, cwd: string, baseBranch?: string): PrResult | null {
|
|
30
|
+
// Determine the current branch
|
|
31
|
+
let headBranch: string;
|
|
32
|
+
try {
|
|
33
|
+
headBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
34
|
+
cwd,
|
|
35
|
+
encoding: "utf-8",
|
|
36
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
37
|
+
}).trim();
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Build PR body
|
|
43
|
+
const bodyParts: string[] = [];
|
|
44
|
+
if (task.description) bodyParts.push(task.description);
|
|
45
|
+
if (task.proof) {
|
|
46
|
+
bodyParts.push("");
|
|
47
|
+
bodyParts.push("## Proof");
|
|
48
|
+
const proof = task.proof as ProofSchema;
|
|
49
|
+
if (proof.notes) bodyParts.push(proof.notes);
|
|
50
|
+
if (proof.tests) bodyParts.push(`Tests: ${proof.tests.passed}/${proof.tests.total} passed`);
|
|
51
|
+
if (proof.ci)
|
|
52
|
+
bodyParts.push(`CI: ${proof.ci.status}${proof.ci.url ? ` (${proof.ci.url})` : ""}`);
|
|
53
|
+
}
|
|
54
|
+
if (task.tags.length > 0) {
|
|
55
|
+
bodyParts.push("");
|
|
56
|
+
bodyParts.push(`Tags: ${task.tags.join(", ")}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const title = `Task ${task.id}: ${task.title}`;
|
|
60
|
+
const body = bodyParts.join("\n") || task.title;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// Push the branch first (gh pr create needs it on remote)
|
|
64
|
+
try {
|
|
65
|
+
execFileSync("git", ["push", "-u", "origin", headBranch], {
|
|
66
|
+
cwd,
|
|
67
|
+
encoding: "utf-8",
|
|
68
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
// Branch may already be pushed, or remote may not exist — continue anyway
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Create the PR
|
|
75
|
+
const output = execFileSync(
|
|
76
|
+
"gh",
|
|
77
|
+
[
|
|
78
|
+
"pr",
|
|
79
|
+
"create",
|
|
80
|
+
"--title",
|
|
81
|
+
title,
|
|
82
|
+
"--body",
|
|
83
|
+
body,
|
|
84
|
+
"--head",
|
|
85
|
+
headBranch,
|
|
86
|
+
...(baseBranch ? ["--base", baseBranch] : []),
|
|
87
|
+
],
|
|
88
|
+
{
|
|
89
|
+
cwd,
|
|
90
|
+
encoding: "utf-8",
|
|
91
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
92
|
+
},
|
|
93
|
+
).trim();
|
|
94
|
+
|
|
95
|
+
// gh pr create outputs the PR URL
|
|
96
|
+
const url = output;
|
|
97
|
+
// Extract PR number from URL (e.g. https://github.com/owner/repo/pull/42)
|
|
98
|
+
const match = url.match(/\/pull\/(\d+)/);
|
|
99
|
+
const number = match ? parseInt(match[1]!, 10) : 0;
|
|
100
|
+
|
|
101
|
+
return { url, number };
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create a GitHub PR for a completed mission.
|
|
109
|
+
* Uses mission.branch as head; if not set, uses current branch.
|
|
110
|
+
*/
|
|
111
|
+
export function createMissionPr(
|
|
112
|
+
mission: Mission,
|
|
113
|
+
cwd: string,
|
|
114
|
+
baseBranch?: string,
|
|
115
|
+
): PrResult | null {
|
|
116
|
+
let headBranch: string;
|
|
117
|
+
if (mission.branch) {
|
|
118
|
+
headBranch = mission.branch;
|
|
119
|
+
} else {
|
|
120
|
+
try {
|
|
121
|
+
headBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
122
|
+
cwd,
|
|
123
|
+
encoding: "utf-8",
|
|
124
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
125
|
+
}).trim();
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const milestonesSummary = mission.milestones
|
|
132
|
+
.sort((a, b) => a.order - b.order)
|
|
133
|
+
.map((m) => `- ${m.id}: ${m.title} [${m.status}]`)
|
|
134
|
+
.join("\n");
|
|
135
|
+
|
|
136
|
+
const title = `Mission: ${mission.title}`;
|
|
137
|
+
const bodyParts: string[] = [];
|
|
138
|
+
if (mission.description) bodyParts.push(mission.description);
|
|
139
|
+
bodyParts.push("");
|
|
140
|
+
bodyParts.push("## Milestones");
|
|
141
|
+
bodyParts.push(milestonesSummary || "No milestones");
|
|
142
|
+
const body = bodyParts.join("\n");
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
try {
|
|
146
|
+
execFileSync("git", ["push", "-u", "origin", headBranch], {
|
|
147
|
+
cwd,
|
|
148
|
+
encoding: "utf-8",
|
|
149
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
150
|
+
});
|
|
151
|
+
} catch {
|
|
152
|
+
// Branch may already be pushed
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const output = execFileSync(
|
|
156
|
+
"gh",
|
|
157
|
+
[
|
|
158
|
+
"pr",
|
|
159
|
+
"create",
|
|
160
|
+
"--title",
|
|
161
|
+
title,
|
|
162
|
+
"--body",
|
|
163
|
+
body,
|
|
164
|
+
"--head",
|
|
165
|
+
headBranch,
|
|
166
|
+
...(baseBranch ? ["--base", baseBranch] : []),
|
|
167
|
+
],
|
|
168
|
+
{
|
|
169
|
+
cwd,
|
|
170
|
+
encoding: "utf-8",
|
|
171
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
172
|
+
},
|
|
173
|
+
).trim();
|
|
174
|
+
|
|
175
|
+
const url = output;
|
|
176
|
+
const match = url.match(/\/pull\/(\d+)/);
|
|
177
|
+
const number = match ? parseInt(match[1]!, 10) : 0;
|
|
178
|
+
return { url, number };
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|