pi-harness-runtime 0.9.15 → 0.9.17
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/autonomous-runtime/README.md +48 -0
- package/packages/autonomous-runtime/dist/inbox.d.ts +75 -0
- package/packages/autonomous-runtime/dist/inbox.d.ts.map +1 -0
- package/packages/autonomous-runtime/dist/inbox.js +212 -0
- package/packages/autonomous-runtime/dist/inbox.js.map +1 -0
- package/packages/autonomous-runtime/dist/index.d.ts +17 -0
- package/packages/autonomous-runtime/dist/index.d.ts.map +1 -0
- package/packages/autonomous-runtime/dist/index.js +17 -0
- package/packages/autonomous-runtime/dist/index.js.map +1 -0
- package/packages/autonomous-runtime/dist/lease.d.ts +78 -0
- package/packages/autonomous-runtime/dist/lease.d.ts.map +1 -0
- package/packages/autonomous-runtime/dist/lease.js +273 -0
- package/packages/autonomous-runtime/dist/lease.js.map +1 -0
- package/packages/autonomous-runtime/dist/types.d.ts +160 -0
- package/packages/autonomous-runtime/dist/types.d.ts.map +1 -0
- package/packages/autonomous-runtime/dist/types.js +31 -0
- package/packages/autonomous-runtime/dist/types.js.map +1 -0
- package/packages/autonomous-runtime/dist/worker.d.ts +63 -0
- package/packages/autonomous-runtime/dist/worker.d.ts.map +1 -0
- package/packages/autonomous-runtime/dist/worker.js +133 -0
- package/packages/autonomous-runtime/dist/worker.js.map +1 -0
- package/packages/autonomous-runtime/package.json +34 -0
- package/packages/autonomous-runtime/src/inbox.ts +266 -0
- package/packages/autonomous-runtime/src/index.ts +22 -0
- package/packages/autonomous-runtime/src/lease.ts +318 -0
- package/packages/autonomous-runtime/src/types.ts +257 -0
- package/packages/autonomous-runtime/test/autonomous-runtime.test.ts +194 -0
- package/packages/autonomous-runtime/test/inbox.test.mjs +266 -0
- package/packages/autonomous-runtime/test/inbox.test.ts +80 -0
- package/packages/autonomous-runtime/test/types.test.ts +68 -0
- package/packages/autonomous-runtime/tsconfig.json +22 -0
- package/packages/autonomous-runtime/tsconfig.tsbuildinfo +1 -0
- package/packages/privilege-broker/README.md +45 -0
- package/packages/privilege-broker/config/privileges.yaml.example +172 -0
- package/packages/privilege-broker/dist/approval.d.ts +23 -0
- package/packages/privilege-broker/dist/approval.d.ts.map +1 -0
- package/packages/privilege-broker/dist/approval.js +45 -0
- package/packages/privilege-broker/dist/approval.js.map +1 -0
- package/packages/privilege-broker/dist/audit.d.ts +38 -0
- package/packages/privilege-broker/dist/audit.d.ts.map +1 -0
- package/packages/privilege-broker/dist/audit.js +125 -0
- package/packages/privilege-broker/dist/audit.js.map +1 -0
- package/packages/privilege-broker/dist/executor.d.ts +25 -0
- package/packages/privilege-broker/dist/executor.d.ts.map +1 -0
- package/packages/privilege-broker/dist/executor.js +83 -0
- package/packages/privilege-broker/dist/executor.js.map +1 -0
- package/packages/privilege-broker/dist/index.d.ts +30 -0
- package/packages/privilege-broker/dist/index.d.ts.map +1 -0
- package/packages/privilege-broker/dist/index.js +5 -0
- package/packages/privilege-broker/dist/index.js.map +1 -0
- package/packages/privilege-broker/dist/registry.d.ts +15 -0
- package/packages/privilege-broker/dist/registry.d.ts.map +1 -0
- package/packages/privilege-broker/dist/registry.js +76 -0
- package/packages/privilege-broker/dist/registry.js.map +1 -0
- package/packages/privilege-broker/dist/types.d.ts +77 -0
- package/packages/privilege-broker/dist/types.d.ts.map +1 -0
- package/packages/privilege-broker/dist/types.js +11 -0
- package/packages/privilege-broker/dist/types.js.map +1 -0
- package/packages/privilege-broker/package.json +35 -0
- package/packages/privilege-broker/src/approval.ts +70 -0
- package/packages/privilege-broker/src/audit.ts +142 -0
- package/packages/privilege-broker/src/executor.ts +110 -0
- package/packages/privilege-broker/src/index.ts +46 -0
- package/packages/privilege-broker/src/registry.ts +98 -0
- package/packages/privilege-broker/src/types.ts +105 -0
- package/packages/privilege-broker/tsconfig.json +22 -0
- package/packages/privilege-broker/tsconfig.tsbuildinfo +1 -0
- package/packages/scheduler-adapter/README.md +36 -0
- package/packages/scheduler-adapter/dist/cron.d.ts +21 -0
- package/packages/scheduler-adapter/dist/cron.d.ts.map +1 -0
- package/packages/scheduler-adapter/dist/cron.js +116 -0
- package/packages/scheduler-adapter/dist/cron.js.map +1 -0
- package/packages/scheduler-adapter/dist/index.d.ts +9 -0
- package/packages/scheduler-adapter/dist/index.d.ts.map +1 -0
- package/packages/scheduler-adapter/dist/index.js +8 -0
- package/packages/scheduler-adapter/dist/index.js.map +1 -0
- package/packages/scheduler-adapter/dist/interface.d.ts +61 -0
- package/packages/scheduler-adapter/dist/interface.d.ts.map +1 -0
- package/packages/scheduler-adapter/dist/interface.js +2 -0
- package/packages/scheduler-adapter/dist/interface.js.map +1 -0
- package/packages/scheduler-adapter/dist/internal.d.ts +30 -0
- package/packages/scheduler-adapter/dist/internal.d.ts.map +1 -0
- package/packages/scheduler-adapter/dist/internal.js +82 -0
- package/packages/scheduler-adapter/dist/internal.js.map +1 -0
- package/packages/scheduler-adapter/dist/systemd.d.ts +31 -0
- package/packages/scheduler-adapter/dist/systemd.d.ts.map +1 -0
- package/packages/scheduler-adapter/dist/systemd.js +203 -0
- package/packages/scheduler-adapter/dist/systemd.js.map +1 -0
- package/packages/scheduler-adapter/package.json +32 -0
- package/packages/scheduler-adapter/src/cron.ts +123 -0
- package/packages/scheduler-adapter/src/index.ts +9 -0
- package/packages/scheduler-adapter/src/interface.ts +61 -0
- package/packages/scheduler-adapter/src/internal.ts +99 -0
- package/packages/scheduler-adapter/src/systemd.ts +221 -0
- package/packages/scheduler-adapter/tsconfig.json +22 -0
- package/packages/scheduler-adapter/tsconfig.tsbuildinfo +1 -0
- package/packages/types/dist/ai-providers.d.ts +0 -36
- package/packages/types/dist/ai-providers.d.ts.map +0 -1
- package/packages/types/dist/ai-providers.js +0 -66
- package/packages/types/dist/ai-providers.js.map +0 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for autonomous-runtime (RFC-0101 Phase 1)
|
|
3
|
+
*
|
|
4
|
+
* Covers: TaskRecord, TaskStatus, TaskLease, TaskInbox, LeaseManager
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
|
|
12
|
+
import type { TaskRecord, TaskLease } from "../src/types.js";
|
|
13
|
+
import { TaskInbox, LeaseManager } from "../src/index.js";
|
|
14
|
+
|
|
15
|
+
const TEST_DIR = mkdtempSync(join(tmpdir(), "pi-autonomous-"));
|
|
16
|
+
const INBOX_DIR = join(TEST_DIR, "inbox");
|
|
17
|
+
const LEASE_DIR = join(TEST_DIR, "leases");
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
try {
|
|
21
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
22
|
+
} catch {
|
|
23
|
+
// ignore
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("TaskRecord types", () => {
|
|
28
|
+
it("accepts valid task shape", () => {
|
|
29
|
+
const task: TaskRecord = {
|
|
30
|
+
id: "task-1",
|
|
31
|
+
status: "pending",
|
|
32
|
+
title: "Test task",
|
|
33
|
+
created_at: new Date().toISOString(),
|
|
34
|
+
updated_at: new Date().toISOString(),
|
|
35
|
+
attempts: 0,
|
|
36
|
+
};
|
|
37
|
+
expect(task.id).toBe("task-1");
|
|
38
|
+
expect(task.status).toBe("pending");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("LeaseManager", () => {
|
|
43
|
+
it("grants exclusive lease to first worker", async () => {
|
|
44
|
+
const mgr = new LeaseManager({ leases_dir: LEASE_DIR });
|
|
45
|
+
const lease = await mgr.acquire("task-1", "worker-1");
|
|
46
|
+
expect(lease).not.toBeNull();
|
|
47
|
+
expect(lease!.worker_id).toBe("worker-1");
|
|
48
|
+
expect(lease!.task_id).toBe("task-1");
|
|
49
|
+
expect(lease!.acquired_at).toBeTruthy();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("denies lease to second worker", async () => {
|
|
53
|
+
const mgr = new LeaseManager({ leases_dir: LEASE_DIR });
|
|
54
|
+
await mgr.acquire("task-1", "worker-1");
|
|
55
|
+
const second = await mgr.acquire("task-1", "worker-2");
|
|
56
|
+
expect(second).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("releases and allows re-acquisition", async () => {
|
|
60
|
+
const mgr = new LeaseManager({ leases_dir: LEASE_DIR });
|
|
61
|
+
await mgr.acquire("task-1", "worker-1");
|
|
62
|
+
await mgr.release("task-1", "worker-1");
|
|
63
|
+
const next = await mgr.acquire("task-1", "worker-2");
|
|
64
|
+
expect(next).not.toBeNull();
|
|
65
|
+
expect(next!.worker_id).toBe("worker-2");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("only owner can release", async () => {
|
|
69
|
+
const mgr = new LeaseManager({ leases_dir: LEASE_DIR });
|
|
70
|
+
await mgr.acquire("task-1", "worker-1");
|
|
71
|
+
const released = await mgr.release("task-1", "worker-2");
|
|
72
|
+
expect(released).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("renewLease extends expiry if owner", async () => {
|
|
76
|
+
const mgr = new LeaseManager({ leases_dir: LEASE_DIR });
|
|
77
|
+
await mgr.acquire("task-1", "worker-1");
|
|
78
|
+
const renewed = await mgr.renewLease("task-1", "worker-1");
|
|
79
|
+
expect(renewed).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("renewLease fails for non-owner", async () => {
|
|
83
|
+
const mgr = new LeaseManager({ leases_dir: LEASE_DIR });
|
|
84
|
+
await mgr.acquire("task-1", "worker-1");
|
|
85
|
+
const renewed = await mgr.renewLease("task-1", "worker-2");
|
|
86
|
+
expect(renewed).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("getLease returns current lease", async () => {
|
|
90
|
+
const mgr = new LeaseManager({ leases_dir: LEASE_DIR });
|
|
91
|
+
const lease = await mgr.acquire("task-1", "worker-1");
|
|
92
|
+
const found = await mgr.getLease("task-1");
|
|
93
|
+
expect(found).toEqual(lease);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("expired lease allows re-acquisition", async () => {
|
|
97
|
+
const mgr = new LeaseManager({ leases_dir: LEASE_DIR, default_ttl_ms: 1 });
|
|
98
|
+
await mgr.acquire("task-1", "worker-1");
|
|
99
|
+
// Wait for expiry
|
|
100
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
101
|
+
const next = await mgr.acquire("task-1", "worker-2");
|
|
102
|
+
expect(next).not.toBeNull();
|
|
103
|
+
expect(next!.worker_id).toBe("worker-2");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("TaskInbox", () => {
|
|
108
|
+
it("accepts and returns pending tasks", async () => {
|
|
109
|
+
const inbox = new TaskInbox({ inbox_dir: INBOX_DIR });
|
|
110
|
+
await inbox.submit({
|
|
111
|
+
title: "Do the thing",
|
|
112
|
+
description: "A test task",
|
|
113
|
+
capability: "read",
|
|
114
|
+
});
|
|
115
|
+
const tasks = await inbox.getPending();
|
|
116
|
+
expect(tasks.length).toBe(1);
|
|
117
|
+
expect(tasks[0].status).toBe("pending");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("assigns sequential integer IDs", async () => {
|
|
121
|
+
const inbox = new TaskInbox({ inbox_dir: INBOX_DIR });
|
|
122
|
+
await inbox.submit({ title: "Task 1", capability: "read" });
|
|
123
|
+
await inbox.submit({ title: "Task 2", capability: "read" });
|
|
124
|
+
const tasks = await inbox.getPending();
|
|
125
|
+
expect(tasks.length).toBe(2);
|
|
126
|
+
expect(tasks[0].id).toMatch(/^\d+$/);
|
|
127
|
+
expect(tasks[1].id).toMatch(/^\d+$/);
|
|
128
|
+
expect(Number(tasks[0].id)).toBeLessThan(Number(tasks[1].id));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("transitions pending → running → completed", async () => {
|
|
132
|
+
const inbox = new TaskInbox({ inbox_dir: INBOX_DIR });
|
|
133
|
+
await inbox.submit({ title: "Test", capability: "read" });
|
|
134
|
+
|
|
135
|
+
const task = (await inbox.getPending())[0];
|
|
136
|
+
const started = await inbox.start(task.id);
|
|
137
|
+
expect(started.status).toBe("running");
|
|
138
|
+
|
|
139
|
+
const completed = await inbox.complete(task.id, { success: true });
|
|
140
|
+
expect(completed.status).toBe("completed");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("fails a running task", async () => {
|
|
144
|
+
const inbox = new TaskInbox({ inbox_dir: INBOX_DIR });
|
|
145
|
+
await inbox.submit({ title: "Fail task", capability: "read" });
|
|
146
|
+
const task = (await inbox.getPending())[0];
|
|
147
|
+
await inbox.start(task.id);
|
|
148
|
+
const failed = await inbox.fail(task.id, "Intentional test failure");
|
|
149
|
+
expect(failed.status).toBe("failed");
|
|
150
|
+
expect(failed.error).toBe("Intentional test failure");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("retries a failed task if max_attempts not exceeded", async () => {
|
|
154
|
+
const inbox = new TaskInbox({
|
|
155
|
+
inbox_dir: INBOX_DIR,
|
|
156
|
+
options: { max_attempts: 2 },
|
|
157
|
+
});
|
|
158
|
+
await inbox.submit({ title: "Retry task", capability: "read" });
|
|
159
|
+
const task = (await inbox.getPending())[0];
|
|
160
|
+
await inbox.start(task.id);
|
|
161
|
+
await inbox.fail(task.id, "First attempt failed");
|
|
162
|
+
const retried = (await inbox.getPending()).find((t) => t.id === task.id);
|
|
163
|
+
expect(retried).not.toBeUndefined();
|
|
164
|
+
expect(retried!.attempts).toBe(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("does not retry if max_attempts exceeded", async () => {
|
|
168
|
+
const inbox = new TaskInbox({
|
|
169
|
+
inbox_dir: INBOX_DIR,
|
|
170
|
+
options: { max_attempts: 1 },
|
|
171
|
+
});
|
|
172
|
+
await inbox.submit({ title: "No retry task", capability: "read" });
|
|
173
|
+
const task = (await inbox.getPending())[0];
|
|
174
|
+
await inbox.start(task.id);
|
|
175
|
+
await inbox.fail(task.id, "Final failure");
|
|
176
|
+
const retried = await inbox.getTask(task.id);
|
|
177
|
+
expect(retried?.status).toBe("failed");
|
|
178
|
+
expect(retried?.attempts).toBe(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("getTask returns specific task by id", async () => {
|
|
182
|
+
const inbox = new TaskInbox({ inbox_dir: INBOX_DIR });
|
|
183
|
+
await inbox.submit({ title: "Specific task", capability: "read" });
|
|
184
|
+
const [task] = await inbox.getPending();
|
|
185
|
+
const found = await inbox.getTask(task.id);
|
|
186
|
+
expect(found?.id).toBe(task.id);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("returns null for non-existent task", async () => {
|
|
190
|
+
const inbox = new TaskInbox({ inbox_dir: INBOX_DIR });
|
|
191
|
+
const found = await inbox.getTask("nonexistent");
|
|
192
|
+
expect(found).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autonomous Runtime — tests for Phase 1 (RFC-0101 §1-4)
|
|
3
|
+
* Plain JS (bun native test globals)
|
|
4
|
+
*/
|
|
5
|
+
import { mkdirSync, rmSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { TaskInbox } from "../src/index.js";
|
|
9
|
+
import { LeaseManager } from "../src/index.js";
|
|
10
|
+
|
|
11
|
+
let testDir;
|
|
12
|
+
|
|
13
|
+
function makeTask(overrides = {}) {
|
|
14
|
+
const id = `task-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
15
|
+
const now = new Date().toISOString();
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
objective: `Test task ${id}`,
|
|
19
|
+
acceptanceCriteria: ["criterion 1"],
|
|
20
|
+
source: { kind: "manual", createdBy: "test" },
|
|
21
|
+
priority: 2,
|
|
22
|
+
capabilities: ["llm.invoke"],
|
|
23
|
+
approvalClass: "automatic_read_only",
|
|
24
|
+
status: "queued",
|
|
25
|
+
attempts: 0,
|
|
26
|
+
maxAttempts: 3,
|
|
27
|
+
createdAt: now,
|
|
28
|
+
updatedAt: now,
|
|
29
|
+
history: [],
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
testDir = join(
|
|
36
|
+
tmpdir(),
|
|
37
|
+
`pi-harness-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
38
|
+
);
|
|
39
|
+
mkdirSync(testDir, { recursive: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
try {
|
|
44
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
45
|
+
} catch {
|
|
46
|
+
/* ignore */
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const tasksPath = () => join(testDir, "tasks.jsonl");
|
|
51
|
+
const leasesDir = () => join(testDir, "claimed");
|
|
52
|
+
|
|
53
|
+
// ─── TaskInbox ────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe("TaskInbox", () => {
|
|
56
|
+
it("append writes one JSONL line", () => {
|
|
57
|
+
const inbox = new TaskInbox({ tasksPath: tasksPath() });
|
|
58
|
+
inbox.append(makeTask());
|
|
59
|
+
const content = readFileSync(tasksPath(), "utf8");
|
|
60
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
61
|
+
expect(lines).toHaveLength(1);
|
|
62
|
+
expect(() => JSON.parse(lines[0])).not.toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("append rejects duplicate ids", () => {
|
|
66
|
+
const inbox = new TaskInbox({ tasksPath: tasksPath() });
|
|
67
|
+
const task = makeTask();
|
|
68
|
+
inbox.append(task);
|
|
69
|
+
expect(() => inbox.append(task)).toThrow();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("list returns tasks sorted by priority then createdAt", () => {
|
|
73
|
+
const inbox = new TaskInbox({ tasksPath: tasksPath() });
|
|
74
|
+
inbox.append(makeTask({ id: "a", priority: 1 }));
|
|
75
|
+
inbox.append(makeTask({ id: "b", priority: 2 }));
|
|
76
|
+
inbox.append(
|
|
77
|
+
makeTask({
|
|
78
|
+
id: "c",
|
|
79
|
+
priority: 1,
|
|
80
|
+
createdAt: new Date(Date.now() + 1000).toISOString(),
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
expect(inbox.list().map((t) => t.id)).toEqual(["a", "c", "b"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("list filters by status", () => {
|
|
87
|
+
const inbox = new TaskInbox({ tasksPath: tasksPath() });
|
|
88
|
+
inbox.append(makeTask({ id: "queued-1", status: "queued" }));
|
|
89
|
+
inbox.append(makeTask({ id: "running-1", status: "running" }));
|
|
90
|
+
inbox.append(makeTask({ id: "completed-1", status: "completed" }));
|
|
91
|
+
expect(inbox.list({ status: "queued" }).map((t) => t.id)).toEqual([
|
|
92
|
+
"queued-1",
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("get returns a task by id", () => {
|
|
97
|
+
const inbox = new TaskInbox({ tasksPath: tasksPath() });
|
|
98
|
+
const task = makeTask({ id: "find-me" });
|
|
99
|
+
inbox.append(task);
|
|
100
|
+
expect(inbox.get("find-me")?.id).toBe("find-me");
|
|
101
|
+
expect(inbox.get("not-found")).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("transition updates status and appends history", () => {
|
|
105
|
+
const inbox = new TaskInbox({ tasksPath: tasksPath() });
|
|
106
|
+
const task = makeTask();
|
|
107
|
+
inbox.append(task);
|
|
108
|
+
const updated = inbox.transition(task.id, "running");
|
|
109
|
+
expect(updated.status).toBe("running");
|
|
110
|
+
expect(updated.history).toHaveLength(1);
|
|
111
|
+
expect(updated.history[0].kind).toBe("transitioned");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("transition throws for unknown tasks", () => {
|
|
115
|
+
const inbox = new TaskInbox({ tasksPath: tasksPath() });
|
|
116
|
+
expect(() => inbox.transition("does-not-exist", "running")).toThrow();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("complete marks status and sets result", () => {
|
|
120
|
+
const inbox = new TaskInbox({ tasksPath: tasksPath() });
|
|
121
|
+
const task = makeTask();
|
|
122
|
+
inbox.append(task);
|
|
123
|
+
const result = {
|
|
124
|
+
taskId: task.id,
|
|
125
|
+
status: "completed",
|
|
126
|
+
acceptanceCriteriaMet: [],
|
|
127
|
+
durationMs: 1000,
|
|
128
|
+
};
|
|
129
|
+
const updated = inbox.complete(task.id, result);
|
|
130
|
+
expect(updated.status).toBe("completed");
|
|
131
|
+
expect(updated.result).toEqual(result);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("fail increments attempts", () => {
|
|
135
|
+
const inbox = new TaskInbox({ tasksPath: tasksPath() });
|
|
136
|
+
const task = makeTask();
|
|
137
|
+
inbox.append(task);
|
|
138
|
+
const updated = inbox.fail(task.id, "Something went wrong");
|
|
139
|
+
expect(updated.status).toBe("retrying");
|
|
140
|
+
expect(updated.failureReason).toBe("Something went wrong");
|
|
141
|
+
expect(updated.attempts).toBe(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("fail promotes to dead_letter when maxAttempts reached", () => {
|
|
145
|
+
const inbox = new TaskInbox({ tasksPath: tasksPath() });
|
|
146
|
+
const task = makeTask({ attempts: 2, maxAttempts: 3 });
|
|
147
|
+
inbox.append(task);
|
|
148
|
+
const updated = inbox.fail(task.id, "Final failure");
|
|
149
|
+
expect(updated.status).toBe("dead_letter");
|
|
150
|
+
expect(updated.failureReason).toBe("Final failure");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("stats returns counts by status", () => {
|
|
154
|
+
const inbox = new TaskInbox({ tasksPath: tasksPath() });
|
|
155
|
+
inbox.append(makeTask({ id: "a", status: "queued" }));
|
|
156
|
+
inbox.append(makeTask({ id: "b", status: "queued" }));
|
|
157
|
+
inbox.append(makeTask({ id: "c", status: "running" }));
|
|
158
|
+
inbox.append(makeTask({ id: "d", status: "completed" }));
|
|
159
|
+
const stats = inbox.stats();
|
|
160
|
+
expect(stats["queued"]).toBe(2);
|
|
161
|
+
expect(stats["running"]).toBe(1);
|
|
162
|
+
expect(stats["completed"]).toBe(1);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ─── LeaseManager ─────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
describe("LeaseManager", () => {
|
|
169
|
+
it("claim writes a lease file atomically", () => {
|
|
170
|
+
const lm = new LeaseManager({ leasesDir: leasesDir() });
|
|
171
|
+
const lease = lm.claim("task-1", "worker-1");
|
|
172
|
+
expect(lease).not.toBeNull();
|
|
173
|
+
expect(lease.taskId).toBe("task-1");
|
|
174
|
+
expect(lease.workerId).toBe("worker-1");
|
|
175
|
+
expect(lease.attempt).toBe(1);
|
|
176
|
+
expect(lease.acquiredAt).toBeTruthy();
|
|
177
|
+
expect(lease.expiresAt).toBeTruthy();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("claim returns null when already claimed by another worker", () => {
|
|
181
|
+
const lm = new LeaseManager({ leasesDir: leasesDir() });
|
|
182
|
+
// Worker-1 claims first, worker-2 tries immediately after.
|
|
183
|
+
// Atomic rename guarantees only one succeeds.
|
|
184
|
+
const first = lm.claim("task-1", "worker-1");
|
|
185
|
+
expect(first).not.toBeNull();
|
|
186
|
+
const second = lm.claim("task-1", "worker-2");
|
|
187
|
+
expect(second).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("heartbeat extends expiry", async () => {
|
|
191
|
+
const lm = new LeaseManager({ leasesDir: leasesDir() });
|
|
192
|
+
lm.claim("task-1", "worker-1");
|
|
193
|
+
const before = new Date(lm.get("task-1").expiresAt).getTime();
|
|
194
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
195
|
+
const refreshed = lm.heartbeat("task-1", "worker-1");
|
|
196
|
+
expect(refreshed).not.toBeNull();
|
|
197
|
+
const after = new Date(refreshed.expiresAt).getTime();
|
|
198
|
+
expect(after).toBeGreaterThan(before - 500);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("heartbeat rejects wrong worker", () => {
|
|
202
|
+
const lm = new LeaseManager({ leasesDir: leasesDir() });
|
|
203
|
+
lm.claim("task-1", "worker-1");
|
|
204
|
+
expect(() => lm.heartbeat("task-1", "wrong-worker")).toThrow();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("release removes the lease file", () => {
|
|
208
|
+
const lm = new LeaseManager({ leasesDir: leasesDir() });
|
|
209
|
+
lm.claim("task-1", "worker-1");
|
|
210
|
+
lm.release("task-1", "worker-1");
|
|
211
|
+
expect(lm.get("task-1")).toBeNull();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("reapAndRelease removes expired leases", async () => {
|
|
215
|
+
const lm = new LeaseManager({ leasesDir: leasesDir(), leaseTtlMs: 50 });
|
|
216
|
+
lm.claim("task-1", "worker-1");
|
|
217
|
+
lm.claim("task-2", "worker-2");
|
|
218
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
219
|
+
const released = lm.reapAndRelease();
|
|
220
|
+
expect(released).toContain("task-1");
|
|
221
|
+
expect(lm.get("task-1")).toBeNull();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("isExpired returns false for fresh leases", () => {
|
|
225
|
+
const lm = new LeaseManager({ leasesDir: leasesDir() });
|
|
226
|
+
const lease = lm.claim("task-1", "worker-1");
|
|
227
|
+
expect(lm.isExpired(lease)).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("isExpired returns true for expired leases", async () => {
|
|
231
|
+
const lm = new LeaseManager({ leasesDir: leasesDir(), leaseTtlMs: 1 });
|
|
232
|
+
lm.claim("task-1", "worker-1");
|
|
233
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
234
|
+
expect(lm.isExpired(lm.get("task-1"))).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("listActive returns only non-expired leases", async () => {
|
|
238
|
+
const lm = new LeaseManager({ leasesDir: leasesDir(), leaseTtlMs: 50 });
|
|
239
|
+
lm.claim("task-1", "worker-1");
|
|
240
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
241
|
+
expect(lm.listActive()).toHaveLength(0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("recoverOnStartup releases orphan leases", async () => {
|
|
245
|
+
const lm = new LeaseManager({ leasesDir: leasesDir(), leaseTtlMs: 50 });
|
|
246
|
+
lm.claim("task-1", "worker-1");
|
|
247
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
248
|
+
const released = lm.recoverOnStartup();
|
|
249
|
+
expect(released).toContain("task-1");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ─── Storage paths ────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
describe("Storage paths", () => {
|
|
256
|
+
it("getInboxDir returns a path under the runtime root", async () => {
|
|
257
|
+
const { getRuntimeRoot, getInboxDir } = await import("../src/types.js");
|
|
258
|
+
expect(getInboxDir()).toContain(".pi/harness/inbox");
|
|
259
|
+
expect(getInboxDir()).toContain(getRuntimeRoot());
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("getLeasesDir returns claimed/ subdirectory", async () => {
|
|
263
|
+
const { getLeasesDir } = await import("../src/types.js");
|
|
264
|
+
expect(getLeasesDir()).toContain("claimed");
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for TaskInbox (filesystem-backed).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
6
|
+
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { TaskInbox } from "../src/inbox.js";
|
|
10
|
+
|
|
11
|
+
const DIR = mkdtempSync(join(tmpdir(), "pi-inbox-test-"));
|
|
12
|
+
|
|
13
|
+
// Set env so inbox uses our temp dir
|
|
14
|
+
process.env.PI_HARNESS_RUNTIME_ROOT = DIR;
|
|
15
|
+
|
|
16
|
+
const inbox = new TaskInbox({ rootDir: DIR });
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await inbox.clear?.();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterAll(() => {
|
|
23
|
+
try { rmSync(DIR, { recursive: true }); } catch { /* ignore */ }
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("TaskInbox.submit", () => {
|
|
27
|
+
it("accepts and returns a pending task", async () => {
|
|
28
|
+
const spec = {
|
|
29
|
+
id: "test-1",
|
|
30
|
+
kind: "once" as const,
|
|
31
|
+
priority: 5,
|
|
32
|
+
spec: {},
|
|
33
|
+
};
|
|
34
|
+
const record = await inbox.submit(spec);
|
|
35
|
+
expect(record.id).toBe("test-1");
|
|
36
|
+
expect(record.status).toBe("pending");
|
|
37
|
+
expect(record.submittedAt).toBeDefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("rejects duplicate ids", async () => {
|
|
41
|
+
const spec = {
|
|
42
|
+
id: "dup-test",
|
|
43
|
+
kind: "once" as const,
|
|
44
|
+
priority: 5,
|
|
45
|
+
spec: {},
|
|
46
|
+
};
|
|
47
|
+
await inbox.submit(spec);
|
|
48
|
+
await expect(inbox.submit(spec)).rejects.toThrow();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("TaskInbox.list", () => {
|
|
53
|
+
it("returns all submitted tasks", async () => {
|
|
54
|
+
await inbox.submit({ id: "l1", kind: "once", priority: 1, spec: {} });
|
|
55
|
+
await inbox.submit({ id: "l2", kind: "once", priority: 2, spec: {} });
|
|
56
|
+
const records = await inbox.list();
|
|
57
|
+
expect(records.length).toBeGreaterThanOrEqual(2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("filters by status", async () => {
|
|
61
|
+
await inbox.submit({ id: "f1", kind: "once", priority: 1, spec: {} });
|
|
62
|
+
const pending = await inbox.list({ status: "pending" });
|
|
63
|
+
expect(pending.every((r) => r.status === "pending")).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("TaskInbox.claim", () => {
|
|
68
|
+
it("returns null when nothing is pending", async () => {
|
|
69
|
+
const lease = await inbox.claim({ workerId: "w1", heartbeatIntervalMs: 30_000 });
|
|
70
|
+
expect(lease).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns a lease for a pending task", async () => {
|
|
74
|
+
await inbox.submit({ id: "claim-1", kind: "once", priority: 5, spec: {} });
|
|
75
|
+
const lease = await inbox.claim({ workerId: "w1", heartbeatIntervalMs: 30_000 });
|
|
76
|
+
expect(lease).not.toBeNull();
|
|
77
|
+
expect(lease!.taskId).toBe("claim-1");
|
|
78
|
+
expect(lease!.status).toBe("active");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for autonomous-runtime types.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from "bun:test";
|
|
6
|
+
import type {
|
|
7
|
+
TaskSpec,
|
|
8
|
+
TaskStatus,
|
|
9
|
+
TaskEvent,
|
|
10
|
+
TaskRecord,
|
|
11
|
+
LeaseStatus,
|
|
12
|
+
WorkerStatus,
|
|
13
|
+
} from "../src/types.js";
|
|
14
|
+
|
|
15
|
+
describe("TaskStatus enum", () => {
|
|
16
|
+
it("has all expected values", () => {
|
|
17
|
+
const values: TaskStatus[] = [
|
|
18
|
+
"pending",
|
|
19
|
+
"claimed",
|
|
20
|
+
"running",
|
|
21
|
+
"completed",
|
|
22
|
+
"failed",
|
|
23
|
+
"cancelled",
|
|
24
|
+
];
|
|
25
|
+
for (const v of values) {
|
|
26
|
+
expect(typeof v).toBe("string");
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("LeaseStatus enum", () => {
|
|
32
|
+
it("has all expected values", () => {
|
|
33
|
+
const values: LeaseStatus[] = [
|
|
34
|
+
"active",
|
|
35
|
+
"renewed",
|
|
36
|
+
"released",
|
|
37
|
+
"expired",
|
|
38
|
+
];
|
|
39
|
+
for (const v of values) {
|
|
40
|
+
expect(typeof v).toBe("string");
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("TaskSpec", () => {
|
|
46
|
+
it("accepts a minimal valid spec", () => {
|
|
47
|
+
const spec: TaskSpec = {
|
|
48
|
+
id: "task-1",
|
|
49
|
+
kind: "once",
|
|
50
|
+
priority: 5,
|
|
51
|
+
spec: {},
|
|
52
|
+
};
|
|
53
|
+
expect(spec.id).toBe("task-1");
|
|
54
|
+
expect(spec.kind).toBe("once");
|
|
55
|
+
expect(spec.priority).toBe(5);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("WorkerStatus", () => {
|
|
60
|
+
it("accepts a valid status", () => {
|
|
61
|
+
const status: WorkerStatus = {
|
|
62
|
+
workerId: "worker-1",
|
|
63
|
+
running: true,
|
|
64
|
+
heartbeatIntervalMs: 30_000,
|
|
65
|
+
};
|
|
66
|
+
expect(status.running).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022", "DOM"],
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"declarationMap": true,
|
|
11
|
+
"sourceMap": true,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"forceConsistentCasingInFileNames": true,
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"composite": true,
|
|
18
|
+
"types": ["node"]
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/**/*"],
|
|
21
|
+
"exclude": ["node_modules", "dist", "test"]
|
|
22
|
+
}
|