@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
165 lines (164 loc) • 9.05 kB
JavaScript
import { randomUUID } from "node:crypto";
import { lstat, mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { atomicInPrivateDir, BridgeError, bridgeRoot, targetSchema, startingTargetSchema, computerName } from "./protocol.js";
import { tryFileLock } from "../governance/locks.js";
const title = z.string().trim().min(1).max(500).refine(s => !/[\x00-\x1f\x7f]/.test(s));
export const inboxTargetSchema = z.union([targetSchema, startingTargetSchema]);
const itemSchema = z.object({
id: z.string().uuid(), kind: z.enum(["manual", "needs-you", "blocked"]), title,
createdAt: z.string().datetime(), updatedAt: z.string().datetime(), state: z.enum(["open", "resolved"]),
resolvedAt: z.string().datetime().optional(), resolution: z.string().max(2000).optional(),
project: z.string().max(200).optional(), computer: z.string().max(253).optional(),
target: inboxTargetSchema.optional(), dispatch: z.string().uuid().optional(), actionId: z.string().max(200).optional(),
source: z.string().max(500).optional(), live: z.boolean().optional(),
}).strict();
export const ownerInboxSchema = z.object({
action: z.enum(["add", "list", "resolve"]).default("list"),
title: title.optional(), project: z.string().max(200).optional(),
id: z.string().uuid().optional(), resolution: z.string().max(2000).optional(),
includeResolved: z.boolean().optional(), computer: computerName.optional(),
}).strict();
// Prompt prose, counters and timestamps are observations, not identities.
function sourceKey(row) {
const t = row.target;
return t ? JSON.stringify([row.computer ?? "local", t.server, t.workspace, t.tab, t.pane, t.source,
"session" in t ? t.session : t.startingToken, row.actionId ?? "waiting"])
: row.dispatch ? `dispatch:${row.dispatch}:${row.actionId ?? "waiting"}` : row.source;
}
/** Reconcile automatic work against current sources; manual work is owner-owned.
* The first reconciliation also migrates old stale/duplicate automatic rows. */
export class OwnerInbox {
sources;
file;
pending = Promise.resolve();
constructor(sources = async () => [], file = path.join(bridgeRoot(), "owner-inbox.json")) {
this.sources = sources;
this.file = file;
}
async read() {
const info = await lstat(this.file).catch(error => { if (error.code === "ENOENT")
return undefined; throw error; });
if (!info)
return [];
if (!info.isFile() || info.isSymbolicLink() || info.size > 4 * 1024 * 1024)
throw new BridgeError(409, "The owner inbox cannot be read safely.");
return z.array(itemSchema).max(10000).parse(JSON.parse(await readFile(this.file, "utf8")));
}
run(input) {
const run = this.pending.then(async () => {
const data = ownerInboxSchema.parse(input);
// Wait asynchronously: another instance in this process must be able to
// finish its write while we contend, just like another CLI process.
await mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
const deadline = Date.now() + 30_000;
let release = tryFileLock(this.file);
while (!release) {
if (Date.now() >= deadline)
throw new BridgeError(503, "The owner inbox is busy; retry the operation.");
await new Promise(resolve => setTimeout(resolve, 20));
release = tryFileLock(this.file);
}
try {
const rows = await this.read(), at = new Date().toISOString();
let changed = false;
// Manual adds/resolves must work even when source discovery is offline.
if (data.action === "list") {
const observed = await this.sources();
const { sources, unavailableDispatches = [] } = Array.isArray(observed) ? { sources: observed } : observed;
const active = new Map(sources.map(source => [sourceKey(source), source]));
const retained = new Set();
const resolve = (row, reason) => {
row.state = "resolved";
row.live = false;
row.resolvedAt = at;
row.updatedAt = at;
row.resolution = reason;
changed = true;
};
for (const row of rows) {
if (row.kind === "manual")
continue;
// Retired history cannot suppress a later wait on the same pane.
// An owner dismissal remains live until its current wait ends.
if (row.state === "resolved" && !row.live)
continue;
const key = sourceKey(row), source = key ? active.get(key) : undefined;
const unavailable = !!row.dispatch && unavailableDispatches.includes(row.dispatch);
const live = !!source && !retained.has(key);
if (row.live !== live) {
row.live = live;
row.updatedAt = at;
changed = true;
}
if (!source) {
if (row.state === "open" && !unavailable)
resolve(row, "stale: source gone");
continue;
}
if (retained.has(key)) {
if (row.state === "open")
resolve(row, "duplicate: source");
continue;
}
retained.add(key);
if (row.state === "open" && JSON.stringify({ ...row, ...source }) !== JSON.stringify(row)) {
Object.assign(row, source, { updatedAt: at });
changed = true;
}
}
for (const [key, source] of active) {
if (retained.has(key))
continue;
if (rows.length >= 10000)
throw new BridgeError(409, "The owner inbox is full.");
rows.push(itemSchema.parse({ ...source, id: randomUUID(), state: "open", live: true, createdAt: at, updatedAt: at }));
changed = true;
}
}
if (data.action === "add") {
if (!data.title)
throw new BridgeError(400, "Name the owner inbox item.");
const item = itemSchema.parse({ id: data.id ?? randomUUID(), kind: "manual", title: data.title, state: "open", createdAt: at, updatedAt: at, ...(data.project ? { project: data.project } : {}) });
const prior = rows.find(row => row.id === item.id);
if (prior && (prior.title !== item.title || prior.project !== item.project || prior.kind !== "manual"))
throw new BridgeError(409, "This inbox id names another item.");
if (!prior) {
if (rows.length >= 10000)
throw new BridgeError(409, "The owner inbox is full.");
rows.push(item);
changed = true;
}
if (changed)
await atomicInPrivateDir(this.file, rows);
return { ok: true, item: prior ?? item };
}
if (data.action === "resolve") {
const item = rows.find(row => row.id === data.id);
if (!item)
throw new BridgeError(404, "No owner inbox item with that id.");
if (item.state !== "resolved") {
item.state = "resolved";
item.resolvedAt = at;
item.updatedAt = at;
item.resolution = data.resolution;
changed = true;
}
if (changed)
await atomicInPrivateDir(this.file, rows);
return { ok: true, item };
}
if (changed)
await atomicInPrivateDir(this.file, rows);
return { ok: true, items: rows.filter(row => data.includeResolved || (row.state === "open" && (row.kind === "manual" || row.live === true))).sort((a, b) => a.createdAt.localeCompare(b.createdAt)) };
}
finally {
release();
}
});
this.pending = run.catch(() => { });
return run;
}
async tick() { await this.run({ action: "list" }); }
}