pi-harness-runtime 0.9.15 → 0.9.16
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,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lease Manager — RFC-0101 §4
|
|
3
|
+
*
|
|
4
|
+
* Manages task leases on disk using atomic write-tmp-then-rename.
|
|
5
|
+
* This prevents double-execution when two workers claim the same task concurrently.
|
|
6
|
+
*
|
|
7
|
+
* Filesystem layout:
|
|
8
|
+
* ```
|
|
9
|
+
* ~/.pi/harness/inbox/
|
|
10
|
+
* ├── tasks.jsonl # task records
|
|
11
|
+
* └── claimed/
|
|
12
|
+
* └── <task-id>.lease.json # one lease file per claimed task
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Safety properties:
|
|
16
|
+
* - Claiming is single-execution: POSIX rename is atomic, so only one writer wins.
|
|
17
|
+
* - A worker that dies: its lease expires, the reaper releases it back to queued.
|
|
18
|
+
* - On startup, recoverOrphanLeases() cleans up any stale leases.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
closeSync,
|
|
22
|
+
existsSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
openSync,
|
|
25
|
+
readFileSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
renameSync,
|
|
28
|
+
unlinkSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
writeSync,
|
|
31
|
+
} from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import type { TaskLease } from "./types.js";
|
|
34
|
+
import { getLeasesDir } from "./types.js";
|
|
35
|
+
|
|
36
|
+
// ─── Errors ───────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export class LeaseError extends Error {
|
|
39
|
+
constructor(msg: string) {
|
|
40
|
+
super(`[LeaseError] ${msg}`);
|
|
41
|
+
this.name = "LeaseError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/** Default lease duration before heartbeat extension is required. */
|
|
48
|
+
export const DEFAULT_LEASE_TTL_MS = 30_000; // 30 s
|
|
49
|
+
|
|
50
|
+
/** Heartbeat interval sent by the worker. */
|
|
51
|
+
export const HEARTBEAT_INTERVAL_MS = 5_000; // 5 s
|
|
52
|
+
|
|
53
|
+
/** Default worker heartbeat interval extension. */
|
|
54
|
+
export const HEARTBEAT_EXTENSION_MS = 30_000; // extend by 30 s
|
|
55
|
+
|
|
56
|
+
// ─── LeaseManager ─────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export interface LeaseOptions {
|
|
59
|
+
leasesDir?: string;
|
|
60
|
+
/** TTL in ms for new leases. Default: DEFAULT_LEASE_TTL_MS */
|
|
61
|
+
leaseTtlMs?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Manages task leases on disk using atomic write-tmp-then-rename.
|
|
66
|
+
*/
|
|
67
|
+
export class LeaseManager {
|
|
68
|
+
private readonly leasesDir: string;
|
|
69
|
+
private readonly leaseTtlMs: number;
|
|
70
|
+
|
|
71
|
+
constructor(options: LeaseOptions = {}) {
|
|
72
|
+
this.leasesDir = options.leasesDir ?? getLeasesDir();
|
|
73
|
+
this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Ensure the claimed/ directory exists. */
|
|
77
|
+
ensureDir(): void {
|
|
78
|
+
if (!existsSync(this.leasesDir)) {
|
|
79
|
+
mkdirSync(this.leasesDir, { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── claim ───────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Atomically claim a task for a worker.
|
|
87
|
+
*
|
|
88
|
+
* Algorithm:
|
|
89
|
+
* 1. Write lease to a temp file in the same directory (same filesystem → fast rename)
|
|
90
|
+
* 2. Attempt atomic rename to `claimed/<taskId>.lease.json`
|
|
91
|
+
* 3. If rename fails (EEXIST) → another worker won → return null
|
|
92
|
+
*
|
|
93
|
+
* @param taskId The task to claim
|
|
94
|
+
* @param workerId The worker claiming it
|
|
95
|
+
* @param attempt Which attempt number (for retry)
|
|
96
|
+
* @returns The TaskLease if won, null if another worker claimed first
|
|
97
|
+
*/
|
|
98
|
+
claim(taskId: string, workerId: string, attempt = 1): TaskLease | null {
|
|
99
|
+
this.ensureDir();
|
|
100
|
+
|
|
101
|
+
const now = new Date().toISOString();
|
|
102
|
+
const expiresAt = new Date(Date.now() + this.leaseTtlMs).toISOString();
|
|
103
|
+
const lease: TaskLease = {
|
|
104
|
+
taskId,
|
|
105
|
+
workerId,
|
|
106
|
+
acquiredAt: now,
|
|
107
|
+
expiresAt,
|
|
108
|
+
heartbeatAt: now,
|
|
109
|
+
attempt,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const targetPath = this._leasePath(taskId);
|
|
113
|
+
|
|
114
|
+
// Use open() with O_CREAT | O_EXCL — this is atomic and fails immediately
|
|
115
|
+
// if another worker has already created the lease file. No tmp file needed.
|
|
116
|
+
let fd: number;
|
|
117
|
+
try {
|
|
118
|
+
fd = openSync(targetPath, "wx", 0o644); // mode 0644 = rw-r--r--
|
|
119
|
+
} catch (err: unknown) {
|
|
120
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
121
|
+
if (code === "EEXIST") {
|
|
122
|
+
return null; // another worker already claimed
|
|
123
|
+
}
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
writeSync(fd, JSON.stringify(lease) + "\n");
|
|
129
|
+
closeSync(fd);
|
|
130
|
+
return lease;
|
|
131
|
+
} catch (err: unknown) {
|
|
132
|
+
try {
|
|
133
|
+
closeSync(fd);
|
|
134
|
+
} catch {
|
|
135
|
+
/* ignore */
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
unlinkSync(targetPath);
|
|
139
|
+
} catch {
|
|
140
|
+
/* ignore */
|
|
141
|
+
}
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── heartbeat ────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Extend a lease's expiry time.
|
|
150
|
+
* Only the worker that holds the lease can extend it.
|
|
151
|
+
*
|
|
152
|
+
* @returns Updated lease, or null if the lease no longer exists
|
|
153
|
+
*/
|
|
154
|
+
heartbeat(taskId: string, workerId: string): TaskLease | null {
|
|
155
|
+
const lease = this.get(taskId);
|
|
156
|
+
if (!lease) return null;
|
|
157
|
+
if (lease.workerId !== workerId) {
|
|
158
|
+
throw new LeaseError(
|
|
159
|
+
`Worker ${workerId} cannot heartbeat lease for ${taskId}: held by ${lease.workerId}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const updated: TaskLease = {
|
|
164
|
+
...lease,
|
|
165
|
+
heartbeatAt: new Date().toISOString(),
|
|
166
|
+
expiresAt: new Date(Date.now() + this.leaseTtlMs).toISOString(),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
this._writeLease(updated);
|
|
170
|
+
return updated;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── release ─────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Explicitly release a lease (task completed or failed without retry).
|
|
177
|
+
*/
|
|
178
|
+
release(taskId: string, workerId: string): void {
|
|
179
|
+
const lease = this.get(taskId);
|
|
180
|
+
if (!lease) return;
|
|
181
|
+
if (lease.workerId !== workerId) {
|
|
182
|
+
throw new LeaseError(
|
|
183
|
+
`Worker ${workerId} cannot release lease for ${taskId}: held by ${lease.workerId}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
this._removeLease(taskId);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── get ─────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/** Read a lease by taskId. Returns null if not found. */
|
|
192
|
+
get(taskId: string): TaskLease | null {
|
|
193
|
+
const path = this._leasePath(taskId);
|
|
194
|
+
if (!existsSync(path)) return null;
|
|
195
|
+
try {
|
|
196
|
+
return JSON.parse(readFileSync(path, "utf8")) as TaskLease;
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── isExpired ────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/** Check if a lease has expired (expiresAt is in the past). */
|
|
205
|
+
isExpired(lease: TaskLease): boolean {
|
|
206
|
+
return new Date(lease.expiresAt).getTime() < Date.now();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── reap ────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Scan all leases and return the ones that have expired.
|
|
213
|
+
* Does NOT remove them — call reapAndRelease() to remove atomically.
|
|
214
|
+
*/
|
|
215
|
+
reap(): TaskLease[] {
|
|
216
|
+
if (!existsSync(this.leasesDir)) return [];
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
const expired: TaskLease[] = [];
|
|
219
|
+
|
|
220
|
+
for (const filename of readdirSync(this.leasesDir)) {
|
|
221
|
+
if (!filename.endsWith(".lease.json")) continue;
|
|
222
|
+
const path = join(this.leasesDir, filename);
|
|
223
|
+
try {
|
|
224
|
+
const lease = JSON.parse(readFileSync(path, "utf8")) as TaskLease;
|
|
225
|
+
if (new Date(lease.expiresAt).getTime() < now) {
|
|
226
|
+
expired.push(lease);
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// Corrupt file — remove it
|
|
230
|
+
try {
|
|
231
|
+
unlinkSync(path);
|
|
232
|
+
} catch {
|
|
233
|
+
/* ignore */
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return expired;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Release all expired leases and return their taskIds.
|
|
243
|
+
* Idempotent — safe to call on every heartbeat interval.
|
|
244
|
+
*/
|
|
245
|
+
reapAndRelease(): string[] {
|
|
246
|
+
const expired = this.reap();
|
|
247
|
+
const released: string[] = [];
|
|
248
|
+
for (const lease of expired) {
|
|
249
|
+
try {
|
|
250
|
+
this._removeLease(lease.taskId);
|
|
251
|
+
released.push(lease.taskId);
|
|
252
|
+
} catch {
|
|
253
|
+
// Already removed — ignore
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return released;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── recoverOrphanLeases ─────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* On worker startup, release any leases held by dead workers.
|
|
263
|
+
* A lease is orphaned if its worker has not re-claimed it since startup.
|
|
264
|
+
*
|
|
265
|
+
* Strategy: remove all leases whose expiresAt has passed.
|
|
266
|
+
* Safe because the next claim will succeed (no other worker holds it).
|
|
267
|
+
*/
|
|
268
|
+
recoverOnStartup(): string[] {
|
|
269
|
+
return this.reapAndRelease();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── listActive ───────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
/** Return all active (non-expired) leases. */
|
|
275
|
+
listActive(): TaskLease[] {
|
|
276
|
+
if (!existsSync(this.leasesDir)) return [];
|
|
277
|
+
const now = Date.now();
|
|
278
|
+
const active: TaskLease[] = [];
|
|
279
|
+
|
|
280
|
+
for (const filename of readdirSync(this.leasesDir)) {
|
|
281
|
+
if (!filename.endsWith(".lease.json")) continue;
|
|
282
|
+
const path = join(this.leasesDir, filename);
|
|
283
|
+
try {
|
|
284
|
+
const lease = JSON.parse(readFileSync(path, "utf8")) as TaskLease;
|
|
285
|
+
if (new Date(lease.expiresAt).getTime() >= now) {
|
|
286
|
+
active.push(lease);
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
// Corrupt — remove
|
|
290
|
+
try {
|
|
291
|
+
unlinkSync(path);
|
|
292
|
+
} catch {
|
|
293
|
+
/* ignore */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return active;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ─── internals ───────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
private _leasePath(taskId: string): string {
|
|
304
|
+
return join(this.leasesDir, `${taskId}.lease.json`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private _writeLease(lease: TaskLease): void {
|
|
308
|
+
this.ensureDir();
|
|
309
|
+
const tmp = `${this._leasePath(lease.taskId)}.tmp`;
|
|
310
|
+
writeFileSync(tmp, JSON.stringify(lease), "utf8");
|
|
311
|
+
renameSync(tmp, this._leasePath(lease.taskId));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private _removeLease(taskId: string): void {
|
|
315
|
+
const path = this._leasePath(taskId);
|
|
316
|
+
if (existsSync(path)) unlinkSync(path);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autonomous Operations Runtime — TypeScript Contracts
|
|
3
|
+
* Based on RFC-0101 §14
|
|
4
|
+
*
|
|
5
|
+
* Consumers: LoopRuntime, privilege-broker, scheduler-adapter, notification-runtime.
|
|
6
|
+
* These types are the canonical contracts. Do not re-define them in other packages.
|
|
7
|
+
*/
|
|
8
|
+
import { env } from "node:process";
|
|
9
|
+
|
|
10
|
+
// ─── Capability model ──────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/** A named capability that the privilege broker may grant. */
|
|
13
|
+
export type CapabilityName =
|
|
14
|
+
| "files.read"
|
|
15
|
+
| "files.write"
|
|
16
|
+
| "files.exec"
|
|
17
|
+
| "git.commit"
|
|
18
|
+
| "git.push"
|
|
19
|
+
| "git.pull"
|
|
20
|
+
| "system.restart"
|
|
21
|
+
| "system.restart-service"
|
|
22
|
+
| "system.logs"
|
|
23
|
+
| "bench.migrate"
|
|
24
|
+
| "bench.build"
|
|
25
|
+
| "bench.restart"
|
|
26
|
+
| "okf.promote-pattern"
|
|
27
|
+
| "okf.promote-lesson"
|
|
28
|
+
| "notify.send"
|
|
29
|
+
| "llm.invoke";
|
|
30
|
+
|
|
31
|
+
/** What approval class a capability requires. */
|
|
32
|
+
export type ApprovalClass =
|
|
33
|
+
| "automatic_read_only" // safe, no side effects
|
|
34
|
+
| "automatic_reversible" // side effects but trivially reversible
|
|
35
|
+
| "human_approval_required" // must ask operator
|
|
36
|
+
| "forbidden"; // never grant, regardless of override
|
|
37
|
+
|
|
38
|
+
// ─── Task lifecycle ───────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export type TaskStatus =
|
|
41
|
+
| "queued"
|
|
42
|
+
| "claimed"
|
|
43
|
+
| "running"
|
|
44
|
+
| "waiting_approval"
|
|
45
|
+
| "waiting_quota"
|
|
46
|
+
| "retrying"
|
|
47
|
+
| "completed"
|
|
48
|
+
| "failed"
|
|
49
|
+
| "dead_letter";
|
|
50
|
+
|
|
51
|
+
/** Immutable log of what happened to a task. */
|
|
52
|
+
export type TaskEvent = {
|
|
53
|
+
ts: string; // ISO-8601
|
|
54
|
+
kind:
|
|
55
|
+
| "claimed"
|
|
56
|
+
| "started"
|
|
57
|
+
| "checkpointed"
|
|
58
|
+
| "progress"
|
|
59
|
+
| "blocked"
|
|
60
|
+
| "approval_requested"
|
|
61
|
+
| "approved"
|
|
62
|
+
| "denied"
|
|
63
|
+
| "failed"
|
|
64
|
+
| "completed"
|
|
65
|
+
| "recovered"
|
|
66
|
+
| "dead_lettered"
|
|
67
|
+
| "transitioned"; // generic status change
|
|
68
|
+
payload?: Record<string, unknown>;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ─── Task record ─────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/** The primary durable record for a task. */
|
|
74
|
+
export interface TaskRecord {
|
|
75
|
+
id: string; // e.g. "task-2026-07-23-001"
|
|
76
|
+
objective: string; // natural-language goal
|
|
77
|
+
acceptanceCriteria: string[]; // verifiable outcomes
|
|
78
|
+
source:
|
|
79
|
+
| { kind: "chat"; userId: string }
|
|
80
|
+
| { kind: "schedule"; scheduleId: string }
|
|
81
|
+
| { kind: "webhook"; url: string }
|
|
82
|
+
| { kind: "subagent"; parentTaskId: string }
|
|
83
|
+
| { kind: "manual"; createdBy: string };
|
|
84
|
+
priority: 0 | 1 | 2 | 3 | 4; // P0..P4 (RFC-0015 convention)
|
|
85
|
+
capabilities: CapabilityName[]; // capabilities this task may need
|
|
86
|
+
approvalClass: ApprovalClass; // initial class (may escalate)
|
|
87
|
+
status: TaskStatus;
|
|
88
|
+
attempts: number;
|
|
89
|
+
maxAttempts: number; // default 3
|
|
90
|
+
context?: ExecutionContext; // planner-provided inputs
|
|
91
|
+
createdAt: string; // ISO-8601
|
|
92
|
+
updatedAt: string; // ISO-8601
|
|
93
|
+
leaseId?: string;
|
|
94
|
+
result?: TaskResult;
|
|
95
|
+
failureReason?: string;
|
|
96
|
+
history: TaskEvent[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Leasing ──────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/** A lease held by a worker on a specific task. */
|
|
102
|
+
export interface TaskLease {
|
|
103
|
+
taskId: string;
|
|
104
|
+
workerId: string;
|
|
105
|
+
acquiredAt: string; // ISO-8601
|
|
106
|
+
expiresAt: string; // ISO-8601
|
|
107
|
+
heartbeatAt: string; // ISO-8601
|
|
108
|
+
attempt: number; // which attempt this lease covers
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── Worker registry ──────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/** Heartbeat emitted by a worker every 5 s to worker-registry.json. */
|
|
114
|
+
export interface WorkerHeartbeat {
|
|
115
|
+
workerId: string;
|
|
116
|
+
startedAt: string;
|
|
117
|
+
lastBeatAt: string;
|
|
118
|
+
capacity: number; // max concurrent tasks
|
|
119
|
+
inflightTaskIds: string[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Privilege broker ─────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/** A resolved grant from the privilege broker. */
|
|
125
|
+
export interface CapabilityGrant {
|
|
126
|
+
name: CapabilityName;
|
|
127
|
+
argv: string[]; // exact, no shell
|
|
128
|
+
envWhitelist?: string[]; // env vars that may be passed
|
|
129
|
+
user: string; // service account to run as
|
|
130
|
+
cwd?: string; // working directory
|
|
131
|
+
timeoutMs: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Approval ─────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/** An in-flight human approval request. */
|
|
137
|
+
export interface ApprovalRequest {
|
|
138
|
+
id: string;
|
|
139
|
+
taskId: string;
|
|
140
|
+
capability: CapabilityName;
|
|
141
|
+
rationale: string; // human-readable description
|
|
142
|
+
signedBy?: { keyFingerprint: string; ts: string };
|
|
143
|
+
decision?: "approved" | "denied";
|
|
144
|
+
decidedAt?: string;
|
|
145
|
+
expiresAt: string; // ISO-8601 — timeout threshold
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Scheduler ───────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/** A scheduled recurring task. */
|
|
151
|
+
export interface ScheduledTask {
|
|
152
|
+
id: string;
|
|
153
|
+
taskTemplate: Omit<
|
|
154
|
+
TaskRecord,
|
|
155
|
+
"id" | "status" | "attempts" | "createdAt" | "updatedAt" | "history"
|
|
156
|
+
>;
|
|
157
|
+
schedule: ScheduleSpec;
|
|
158
|
+
enabled: boolean;
|
|
159
|
+
lastFiredAt?: string;
|
|
160
|
+
nextFireAt?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type ScheduleSpec =
|
|
164
|
+
| { kind: "cron"; expression: string }
|
|
165
|
+
| { kind: "interval"; intervalMs: number }
|
|
166
|
+
| { kind: "once"; at: string };
|
|
167
|
+
|
|
168
|
+
// ─── Notification ─────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
export type RuntimeNotificationEvent =
|
|
171
|
+
| "task.claimed"
|
|
172
|
+
| "task.started"
|
|
173
|
+
| "task.completed"
|
|
174
|
+
| "task.failed"
|
|
175
|
+
| "task.waiting_approval"
|
|
176
|
+
| "task.dead_lettered"
|
|
177
|
+
| "lease.expired"
|
|
178
|
+
| "worker.started"
|
|
179
|
+
| "worker.stopped"
|
|
180
|
+
| "approval.approved"
|
|
181
|
+
| "approval.denied"
|
|
182
|
+
| "approval.expired";
|
|
183
|
+
|
|
184
|
+
export interface NotificationEvent {
|
|
185
|
+
kind: RuntimeNotificationEvent;
|
|
186
|
+
ts: string;
|
|
187
|
+
taskId?: string;
|
|
188
|
+
workerId?: string;
|
|
189
|
+
payload: Record<string, unknown>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Checkpoint ───────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
export interface Checkpoint {
|
|
195
|
+
taskId: string;
|
|
196
|
+
attempt: number;
|
|
197
|
+
state: Record<string, unknown>;
|
|
198
|
+
savedAt: string; // ISO-8601
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── Execution context ────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/** The context given to the loop runtime when executing a task. */
|
|
204
|
+
export interface ExecutionContext {
|
|
205
|
+
taskId: string;
|
|
206
|
+
workerId: string;
|
|
207
|
+
inputs: Record<string, unknown>;
|
|
208
|
+
capabilitiesGranted: CapabilityName[];
|
|
209
|
+
approvalClass: ApprovalClass;
|
|
210
|
+
lease: TaskLease;
|
|
211
|
+
checkpoint?: Checkpoint;
|
|
212
|
+
okfBundles: string[]; // content hashes available to planner
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Task result ─────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
export interface TaskResult {
|
|
218
|
+
taskId: string;
|
|
219
|
+
status: "completed" | "failed" | "dead_letter";
|
|
220
|
+
deliverables?: { path: string; mime: string }[];
|
|
221
|
+
acceptanceCriteriaMet: {
|
|
222
|
+
criterion: string;
|
|
223
|
+
passed: boolean;
|
|
224
|
+
evidence?: string;
|
|
225
|
+
}[];
|
|
226
|
+
durationMs: number;
|
|
227
|
+
modelUsage?: { inputTokens: number; outputTokens: number; provider: string };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── Storage paths ────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
const DEFAULT_ROOT = `${env["HOME"] ?? "."}/.pi/harness`;
|
|
233
|
+
|
|
234
|
+
/** Returns the standard storage root for the runtime. */
|
|
235
|
+
export function getRuntimeRoot(): string {
|
|
236
|
+
return env["PI_HARNESS_RUNTIME_ROOT"] ?? DEFAULT_ROOT;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Returns the inbox directory path. */
|
|
240
|
+
export function getInboxDir(): string {
|
|
241
|
+
return `${getRuntimeRoot()}/inbox`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Returns the claimed leases directory path. */
|
|
245
|
+
export function getLeasesDir(): string {
|
|
246
|
+
return `${getInboxDir()}/claimed`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Returns the task JSONL file path. */
|
|
250
|
+
export function getTasksPath(): string {
|
|
251
|
+
return `${getInboxDir()}/tasks.jsonl`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Returns the worker registry path. */
|
|
255
|
+
export function getWorkerRegistryPath(): string {
|
|
256
|
+
return `${getRuntimeRoot()}/worker-registry.json`;
|
|
257
|
+
}
|