Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom

This commit is contained in:
2026-05-08 15:59:25 +10:00
parent d0d1d9b045
commit 31b4110c87
457 changed files with 85157 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { DEFAULT_CACHE, DEFAULT_PATHS } from "../config/defaults.ts";
import type { TeamRunManifest } from "./types.ts";
import { atomicWriteJson } from "./atomic-write.ts";
import { userCrewRoot } from "../utils/paths.ts";
import { isSafePathId } from "../utils/safe-paths.ts";
export interface ActiveRunRegistryEntry {
runId: string;
cwd: string;
stateRoot: string;
manifestPath: string;
updatedAt: string;
}
function registryPath(): string {
return path.join(userCrewRoot(), DEFAULT_PATHS.state.runsSubdir, "active-run-index.json");
}
function registryLockPath(): string {
return `${registryPath()}.lock`;
}
function sleepSync(ms: number): void {
try {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
} catch {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
// Best-effort fallback for rare runtimes without Atomics.wait.
}
}
}
function lockCreatedAt(raw: string): number | undefined {
try {
const parsed = JSON.parse(raw) as { createdAt?: unknown };
if (typeof parsed.createdAt !== "string") return undefined;
const time = Date.parse(parsed.createdAt);
return Number.isNaN(time) ? undefined : time;
} catch {
return undefined;
}
}
function removeStaleRegistryLock(lockPath: string, staleMs: number): boolean {
try {
const stat = fs.statSync(lockPath);
const createdAt = lockCreatedAt(fs.readFileSync(lockPath, "utf-8")) ?? stat.mtimeMs;
if (Date.now() - createdAt <= staleMs) return false;
fs.rmSync(lockPath, { force: true });
return true;
} catch {
return false;
}
}
function withRegistryLock<T>(fn: () => T): T {
const filePath = registryLockPath();
const staleMs = 30_000;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
let attempt = 0;
const deadline = Date.now() + staleMs * 2;
while (true) {
try {
const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
try {
fs.writeSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
} finally {
fs.closeSync(fd);
}
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EEXIST") throw error;
if (!removeStaleRegistryLock(filePath, staleMs) && Date.now() > deadline) throw new Error("Active-run registry is locked by another operation.");
sleepSync(Math.min(250, 25 * 2 ** attempt));
attempt += 1;
}
}
try {
return fn();
} finally {
try {
fs.rmSync(filePath, { force: true });
} catch {
// Best-effort cleanup.
}
}
}
function normalizeEntry(value: unknown): ActiveRunRegistryEntry | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
const runId = typeof record.runId === "string" ? record.runId : undefined;
const cwd = typeof record.cwd === "string" ? record.cwd : undefined;
const stateRoot = typeof record.stateRoot === "string" ? record.stateRoot : undefined;
const manifestPath = typeof record.manifestPath === "string" ? record.manifestPath : undefined;
const updatedAt = typeof record.updatedAt === "string" ? record.updatedAt : undefined;
if (!runId || !isSafePathId(runId) || !cwd || !stateRoot || !manifestPath || !updatedAt) return undefined;
if (path.basename(stateRoot) !== runId) return undefined;
if (path.resolve(manifestPath) !== path.resolve(path.join(stateRoot, DEFAULT_PATHS.state.manifestFile))) return undefined;
return { runId, cwd, stateRoot, manifestPath, updatedAt };
}
export function readActiveRunRegistry(maxEntries = DEFAULT_CACHE.manifestMaxEntries): ActiveRunRegistryEntry[] {
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(registryPath(), "utf-8"));
} catch {
return [];
}
const entries = Array.isArray(parsed) ? parsed.map(normalizeEntry).filter((entry): entry is ActiveRunRegistryEntry => entry !== undefined) : [];
const byId = new Map<string, ActiveRunRegistryEntry>();
for (const entry of entries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))) {
if (!byId.has(entry.runId)) byId.set(entry.runId, entry);
}
return [...byId.values()].slice(0, Math.max(0, maxEntries));
}
function writeEntries(entries: ActiveRunRegistryEntry[]): void {
fs.mkdirSync(path.dirname(registryPath()), { recursive: true });
atomicWriteJson(registryPath(), entries.slice(0, DEFAULT_CACHE.manifestMaxEntries));
}
export function registerActiveRun(manifest: TeamRunManifest): void {
const entry: ActiveRunRegistryEntry = {
runId: manifest.runId,
cwd: manifest.cwd,
stateRoot: manifest.stateRoot,
manifestPath: path.join(manifest.stateRoot, DEFAULT_PATHS.state.manifestFile),
updatedAt: manifest.updatedAt,
};
withRegistryLock(() => {
writeEntries([entry, ...readActiveRunRegistry().filter((item) => item.runId !== manifest.runId)]);
});
}
export function unregisterActiveRun(runId: string): void {
if (!isSafePathId(runId)) return;
withRegistryLock(() => {
writeEntries(readActiveRunRegistry().filter((entry) => entry.runId !== runId));
});
}
export function activeRunEntries(): ActiveRunRegistryEntry[] {
const entries: ActiveRunRegistryEntry[] = [];
for (const entry of readActiveRunRegistry()) {
try {
if (!fs.existsSync(entry.stateRoot) || !fs.existsSync(entry.manifestPath)) continue;
if (fs.lstatSync(entry.stateRoot).isSymbolicLink()) continue;
const manifest = JSON.parse(fs.readFileSync(entry.manifestPath, "utf-8")) as { status?: unknown };
if (manifest.status !== "queued" && manifest.status !== "planning" && manifest.status !== "running") continue;
entries.push(entry);
} catch {
// Ignore stale entries; callers filter active status from manifests.
}
}
return entries;
}
export function activeRunRoots(): string[] {
return [...new Set(activeRunEntries().map((entry) => path.dirname(entry.stateRoot)))];
}

View File

@@ -0,0 +1,126 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { createHash } from "node:crypto";
import type { ArtifactDescriptor } from "./types.ts";
import { atomicWriteFile } from "./atomic-write.ts";
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
import { redactSecretString } from "../utils/redaction.ts";
function hashContent(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
export const CLEANUP_MARKER_FILE = ".last-cleanup";
export interface ArtifactWriteOptions {
kind: ArtifactDescriptor["kind"];
relativePath: string;
content: string;
producer: string;
retention?: ArtifactDescriptor["retention"];
}
export interface ArtifactCleanupOptions {
maxAgeDays: number;
maxAgeMs?: number;
markerFile?: string;
scanGraceMs?: number;
}
function parseAgeDays(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
return Math.floor(value);
}
function nowMs(): number {
return Date.now();
}
function readMarkerMtime(artifactsRoot: string, markerFile: string): number | undefined {
try {
return fs.statSync(path.join(artifactsRoot, markerFile)).mtimeMs;
} catch {
return undefined;
}
}
function shouldCleanup(artifactsRoot: string, markerFile: string, scanGraceMs: number): boolean {
const marker = readMarkerMtime(artifactsRoot, markerFile);
if (marker === undefined) return true;
return nowMs() - marker >= scanGraceMs;
}
export function writeCleanupMarker(artifactsRoot: string, markerFile: string): void {
fs.mkdirSync(artifactsRoot, { recursive: true });
fs.writeFileSync(path.join(artifactsRoot, markerFile), String(nowMs()), "utf-8");
}
export function cleanupOldArtifacts(artifactsRoot: string, options: ArtifactCleanupOptions): void {
if (!fs.existsSync(artifactsRoot)) return;
const maxAgeDays = parseAgeDays(options.maxAgeDays);
if (maxAgeDays === undefined) return;
const markerFile = options.markerFile ?? CLEANUP_MARKER_FILE;
const scanGraceMs = options.scanGraceMs ?? 24 * 60 * 60 * 1000;
if (!shouldCleanup(artifactsRoot, markerFile, scanGraceMs)) return;
const maxAgeMs = options.maxAgeMs ?? maxAgeDays * 24 * 60 * 60 * 1000;
const cutoff = nowMs() - maxAgeMs;
let didCleanup = false;
try {
const entries = fs.readdirSync(artifactsRoot, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === markerFile) continue;
const target = path.join(artifactsRoot, entry.name);
try {
const stat = fs.statSync(target);
if (stat.mtimeMs >= cutoff) continue;
if (stat.isDirectory()) {
fs.rmSync(target, { recursive: true, force: true });
} else {
fs.unlinkSync(target);
}
didCleanup = true;
} catch {
// Ignore cleanup races and permission issues in best-effort mode.
}
}
writeCleanupMarker(artifactsRoot, markerFile);
} catch {
// Ignore unreadable roots in best-effort mode.
}
if (!didCleanup) writeCleanupMarker(artifactsRoot, markerFile);
}
function resolveInside(baseDir: string, relativePath: string): string {
const normalizedRelativePath = relativePath.replaceAll("\\", "/").replace(/^\.\/+/, "");
if (!normalizedRelativePath || normalizedRelativePath.split("/").some((segment) => segment === "..") || path.isAbsolute(normalizedRelativePath)) {
throw new Error(`Invalid artifact path: ${relativePath}`);
}
const base = path.resolve(baseDir);
const resolved = path.resolve(base, normalizedRelativePath);
const relative = path.relative(base, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Invalid artifact path: ${relativePath}`);
return resolved;
}
export function writeArtifact(artifactsRoot: string, options: ArtifactWriteOptions): ArtifactDescriptor {
const filePath = resolveInside(artifactsRoot, options.relativePath);
fs.mkdirSync(artifactsRoot, { recursive: true });
if (fs.lstatSync(artifactsRoot).isSymbolicLink()) throw new Error(`Artifacts root is a symbolic link — not allowed: ${artifactsRoot}`);
resolveRealContainedPath(path.dirname(artifactsRoot), path.basename(artifactsRoot));
fs.mkdirSync(path.dirname(filePath), { recursive: true });
resolveRealContainedPath(artifactsRoot, path.dirname(filePath));
// Compute hash on original content for integrity verification.
const contentHash = hashContent(options.content);
const content = redactSecretString(options.content);
atomicWriteFile(filePath, content);
const stats = fs.statSync(filePath);
return {
kind: options.kind,
path: filePath,
createdAt: new Date().toISOString(),
producer: options.producer,
sizeBytes: stats.size,
contentHash,
retention: options.retention ?? "run",
};
}

View File

@@ -0,0 +1,122 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { logInternalError } from "../utils/internal-error.ts";
const RETRYABLE_RENAME_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
function sleepSync(ms: number): void {
try {
const buffer = new SharedArrayBuffer(4);
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
} catch {
// Fallback for environments without SharedArrayBuffer / Atomics.wait support.
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
// Busy-wait — only used as last-resort, retry counts are capped.
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRetryableRenameError(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && RETRYABLE_RENAME_CODES.has(String((error as NodeJS.ErrnoException).code)));
}
export function __test__renameWithRetry(tempPath: string, filePath: string, retries = 5, rename: (oldPath: string, newPath: string) => void = fs.renameSync): void {
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
rename(tempPath, filePath);
return;
} catch (error) {
lastError = error;
if (!isRetryableRenameError(error) || attempt === retries) break;
sleepSync(Math.min(250, 10 * 2 ** attempt));
}
}
throw lastError;
}
export async function __test__renameWithRetryAsync(tempPath: string, filePath: string, retries = 5, rename: (oldPath: string, newPath: string) => Promise<void> = (source, destination) => fs.promises.rename(source, destination)): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
await rename(tempPath, filePath);
return;
} catch (error) {
lastError = error;
if (!isRetryableRenameError(error) || attempt === retries) break;
await sleep(Math.min(250, 10 * 2 ** attempt));
}
}
throw lastError;
}
export function atomicWriteFile(filePath: string, content: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
try {
fs.writeFileSync(tempPath, content, "utf-8");
__test__renameWithRetry(tempPath, filePath);
} catch (error) {
try {
fs.rmSync(tempPath, { force: true });
} catch (cleanupError) {
logInternalError("atomic-write.cleanup", cleanupError, `tempPath=${tempPath}`);
}
throw error;
}
}
export async function atomicWriteFileAsync(filePath: string, content: string): Promise<void> {
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
try {
await fs.promises.writeFile(tempPath, content, "utf-8");
try {
await __test__renameWithRetryAsync(tempPath, filePath);
} catch (renameError) {
let matches = false;
try {
const existing = await fs.promises.readFile(filePath, "utf-8");
matches = existing === content;
} catch {
/* ignore */
}
if (matches) {
try {
await fs.promises.rm(tempPath, { force: true });
} catch (cleanupError) {
logInternalError("atomic-write.cleanupAsync", cleanupError, `tempPath=${tempPath}`);
}
return;
}
throw renameError;
}
} catch (error) {
try {
await fs.promises.rm(tempPath, { force: true });
} catch (cleanupError) {
logInternalError("atomic-write.cleanupAsync", cleanupError, `tempPath=${tempPath}`);
}
throw error;
}
}
export function atomicWriteJson<T>(filePath: string, value: T): void {
atomicWriteFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
export async function atomicWriteJsonAsync<T>(filePath: string, value: T): Promise<void> {
await atomicWriteFileAsync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
export function readJsonFile<T>(filePath: string): T | undefined {
if (!fs.existsSync(filePath)) return undefined;
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
}

View File

@@ -0,0 +1,109 @@
export const TEAM_RUN_STATUSES = ["queued", "planning", "running", "blocked", "completed", "failed", "cancelled"] as const;
export type TeamRunStatus = typeof TEAM_RUN_STATUSES[number];
export const TEAM_TASK_STATUSES = ["queued", "running", "waiting", "completed", "failed", "cancelled", "skipped"] as const;
export type TeamTaskStatus = typeof TEAM_TASK_STATUSES[number];
export const TEAM_TERMINAL_RUN_STATUSES: ReadonlySet<TeamRunStatus> = new Set(["blocked", "completed", "failed", "cancelled"]);
export const TEAM_TERMINAL_TASK_STATUSES: ReadonlySet<TeamTaskStatus> = new Set(["completed", "failed", "cancelled", "skipped"]);
export const TEAM_RUN_STATUS_TRANSITIONS: Readonly<Record<TeamRunStatus, readonly TeamRunStatus[]>> = {
queued: ["planning", "running", "cancelled", "failed"],
planning: ["running", "blocked", "cancelled", "failed"],
running: ["blocked", "completed", "failed", "cancelled"],
blocked: ["running", "cancelled", "failed"],
completed: ["running", "cancelled"],
failed: ["running", "cancelled"],
cancelled: ["running"],
};
export const TEAM_TASK_STATUS_TRANSITIONS: Readonly<Record<TeamTaskStatus, readonly TeamTaskStatus[]>> = {
queued: ["running", "cancelled", "skipped", "failed"],
running: ["completed", "failed", "cancelled", "queued", "waiting"],
waiting: ["running", "queued", "completed", "failed", "cancelled"],
completed: ["queued"],
failed: ["queued", "cancelled"],
cancelled: ["queued"],
skipped: ["queued", "cancelled"],
};
export const TEAM_EVENT_TYPES = [
"run.created",
"run.queued",
"run.planning",
"run.running",
"run.blocked",
"run.completed",
"run.failed",
"run.cancelled",
"task.started",
"task.progress",
"task.blocked",
"task.green",
"task.red",
"task.completed",
"task.failed",
"task.cancelled",
"task.skipped",
"review.approved",
"review.rejected",
"policy.action",
"policy.escalated",
"recovery.attempted",
"recovery.escalated",
"branch.stale",
"mailbox.timeout",
"worktree.cleanup",
"worktree.dirty",
"async.spawned",
"async.started",
"async.completed",
"async.failed",
"async.stale",
"task.waiting",
"task.resumed",
"supervisor.contact",
] as const;
export type TeamEventType = typeof TEAM_EVENT_TYPES[number];
export const TEAM_WAKEABLE_EVENT_TYPES: ReadonlySet<TeamEventType> = new Set([
"run.blocked",
"run.completed",
"run.failed",
"run.cancelled",
"task.completed",
"task.failed",
"task.cancelled",
"task.skipped",
"async.completed",
"async.failed",
"async.stale",
]);
export function isTeamRunStatus(value: unknown): value is TeamRunStatus {
return typeof value === "string" && TEAM_RUN_STATUSES.includes(value as TeamRunStatus);
}
export function isTeamTaskStatus(value: unknown): value is TeamTaskStatus {
return typeof value === "string" && TEAM_TASK_STATUSES.includes(value as TeamTaskStatus);
}
export function isTerminalRunStatus(status: TeamRunStatus): boolean {
return TEAM_TERMINAL_RUN_STATUSES.has(status);
}
export function isTerminalTaskStatus(status: TeamTaskStatus): boolean {
return TEAM_TERMINAL_TASK_STATUSES.has(status);
}
export function canTransitionRunStatus(from: TeamRunStatus, to: TeamRunStatus): boolean {
return from === to || (TEAM_RUN_STATUS_TRANSITIONS[from]?.includes(to) ?? false);
}
export function canTransitionTaskStatus(from: TeamTaskStatus, to: TeamTaskStatus): boolean {
return from === to || (TEAM_TASK_STATUS_TRANSITIONS[from]?.includes(to) ?? false);
}
export function isWakeableTeamEventType(type: TeamEventType): boolean {
return TEAM_WAKEABLE_EVENT_TYPES.has(type);
}

View File

@@ -0,0 +1,190 @@
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { DEFAULT_EVENT_LOG } from "../config/defaults.ts";
import { atomicWriteFile } from "./atomic-write.ts";
import { logInternalError } from "../utils/internal-error.ts";
import { redactSecrets } from "../utils/redaction.ts";
export type TeamEventProvenance = "live_worker" | "test" | "healthcheck" | "replay" | "api" | "background" | "team_runner";
export type TeamWatcherAction = "act" | "observe" | "ignore";
export interface TeamEventSessionIdentity {
title: string;
workspace: string;
purpose: string;
placeholderReason?: string;
}
export interface TeamEventOwnership {
owner: string;
workflowScope: string;
watcherAction: TeamWatcherAction;
}
export interface TeamEventMetadata {
seq: number;
provenance: TeamEventProvenance;
sessionIdentity?: TeamEventSessionIdentity;
ownership?: TeamEventOwnership;
nudgeId?: string;
appended?: boolean;
fingerprint?: string;
confidence?: "low" | "medium" | "high";
}
export interface TeamEvent {
time: string;
type: string;
runId: string;
taskId?: string;
message?: string;
data?: Record<string, unknown>;
metadata?: TeamEventMetadata;
}
export type AppendTeamEvent = Omit<TeamEvent, "time" | "metadata"> & { metadata?: Partial<TeamEventMetadata> };
const TERMINAL_EVENT_TYPES = new Set<string>(DEFAULT_EVENT_LOG.terminalEventTypes);
const MAX_EVENTS_BYTES = 50 * 1024 * 1024;
const sequenceCache = new Map<string, { size: number; mtimeMs: number; seq: number }>();
export function sequencePath(eventsPath: string): string {
return `${eventsPath}.seq`;
}
function parseSequence(raw: string): number | undefined {
const value = Number.parseInt(raw.trim(), 10);
return Number.isInteger(value) && value >= 0 ? value : undefined;
}
export function scanSequence(eventsPath: string): number {
if (!fs.existsSync(eventsPath)) return 0;
let max = 0;
for (const line of fs.readFileSync(eventsPath, "utf-8").split("\n")) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line) as TeamEvent;
max = Math.max(max, event.metadata?.seq ?? 0);
} catch { /* skip corrupt lines without incrementing sequence */ }
}
return max;
}
function readStoredSequence(eventsPath: string): number | undefined {
try {
return parseSequence(fs.readFileSync(sequencePath(eventsPath), "utf-8"));
} catch {
return undefined;
}
}
function nextSequence(eventsPath: string): number {
if (!fs.existsSync(eventsPath)) return 1;
const stat = fs.statSync(eventsPath);
const cached = sequenceCache.get(eventsPath);
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
return cached.seq + 1;
}
let current = readStoredSequence(eventsPath);
if (current === undefined || (cached && stat.size < cached.size)) {
current = scanSequence(eventsPath);
}
sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: current });
return current + 1;
}
function persistSequence(eventsPath: string, seq: number): void {
try {
atomicWriteFile(sequencePath(eventsPath), String(seq));
} catch (error) {
logInternalError("event-log.persist-sequence-file", error, `eventsPath=${eventsPath}`);
}
}
export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId" | "taskId" | "data">): string {
return createHash("sha256").update(JSON.stringify({ type: event.type, runId: event.runId, taskId: event.taskId, data: event.data ?? null })).digest("hex").slice(0, 16);
}
export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEvent {
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
const baseMetadata = event.metadata;
let metadata: TeamEventMetadata = {
seq: baseMetadata?.seq ?? nextSequence(eventsPath),
provenance: baseMetadata?.provenance ?? "team_runner",
...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
};
const fullEvent: TeamEvent = {
time: new Date().toISOString(),
...event,
metadata,
};
if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
metadata = { ...metadata, fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent) };
fullEvent.metadata = metadata;
}
try {
if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
logInternalError("event-log.size-limit", new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes`), `eventsPath=${eventsPath}`);
return { ...fullEvent, metadata: { ...(fullEvent.metadata ?? { seq: 0, provenance: "team_runner" }), appended: false } };
}
} catch (error) {
logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
}
fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
const seq = fullEvent.metadata?.seq ?? 0;
try {
const stat = fs.statSync(eventsPath);
sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
persistSequence(eventsPath, seq);
} catch (error) {
logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
}
return fullEvent;
}
export function readEvents(eventsPath: string): TeamEvent[] {
if (!fs.existsSync(eventsPath)) return [];
return fs.readFileSync(eventsPath, "utf-8")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as TeamEvent);
}
export interface EventCursorOptions {
sinceSeq?: number;
limit?: number;
}
function positiveInteger(value: number | undefined): number | undefined {
return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined;
}
export function readEventsCursor(eventsPath: string, options: EventCursorOptions = {}): { events: TeamEvent[]; nextSeq: number; total: number } {
const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
const limit = positiveInteger(options.limit);
const all = readEvents(eventsPath);
const filtered = all.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
return { events, nextSeq: returnedMaxSeq, total: filtered.length };
}
export function dedupeTerminalEvents(events: TeamEvent[]): TeamEvent[] {
const seen = new Set<string>();
const output: TeamEvent[] = [];
for (const event of events) {
const fingerprint = event.metadata?.fingerprint;
if (fingerprint && TERMINAL_EVENT_TYPES.has(event.type)) {
if (seen.has(fingerprint)) continue;
seen.add(fingerprint);
}
output.push(event);
}
return output;
}

View File

@@ -0,0 +1,82 @@
import * as fs from "node:fs";
import { redactJsonLine } from "../utils/redaction.ts";
export interface DrainableSource {
pause(): void;
resume(): void;
}
export interface JsonlWriteStream {
write(chunk: string): boolean;
once(event: "drain", listener: () => void): JsonlWriteStream;
end(callback?: () => void): void;
}
const DEFAULT_MAX_JSONL_BYTES = 50 * 1024 * 1024;
export interface JsonlWriterDeps {
createWriteStream?: (filePath: string) => JsonlWriteStream;
maxBytes?: number;
}
export interface JsonlWriter {
writeLine(line: string): void;
close(): Promise<void>;
}
export function createJsonlWriter(filePath: string | undefined, source: DrainableSource, deps: JsonlWriterDeps = {}): JsonlWriter {
if (!filePath) {
return {
writeLine() {},
async close() {},
};
}
const createWriteStream = deps.createWriteStream ?? ((targetPath: string) => fs.createWriteStream(targetPath, { flags: "a" }));
let stream: JsonlWriteStream | undefined;
try {
stream = createWriteStream(filePath);
} catch {
return {
writeLine() {},
async close() {},
};
}
let backpressured = false;
let closed = false;
let bytesWritten = 0;
const maxBytes = deps.maxBytes ?? DEFAULT_MAX_JSONL_BYTES;
return {
writeLine(line: string) {
if (!stream || closed || !line.trim()) return;
const safeLine = redactJsonLine(line);
const chunk = `${safeLine}\n`;
const chunkBytes = Buffer.byteLength(chunk, "utf-8");
if (bytesWritten + chunkBytes > maxBytes) return;
try {
const ok = stream.write(chunk);
bytesWritten += chunkBytes;
if (!ok && !backpressured) {
backpressured = true;
source.pause();
stream.once("drain", () => {
backpressured = false;
if (!closed) source.resume();
});
}
} catch (writeError) {
// Log the error — silently dropping events is dangerous.
process.stderr.write(`[pi-crew] jsonl-writer: write failed ${filePath}: ${writeError instanceof Error ? writeError.message : String(writeError)}\n`);
}
},
async close() {
if (!stream || closed) return;
closed = true;
const current = stream;
stream = undefined;
await new Promise<void>((resolve) => current.end(() => resolve()));
},
};
}

View File

@@ -0,0 +1,157 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { TeamRunManifest } from "./types.ts";
import { DEFAULT_LOCKS } from "../config/defaults.ts";
export interface RunLockOptions {
staleMs?: number;
}
const DEFAULT_STALE_MS = DEFAULT_LOCKS.staleMs;
function lockPath(manifest: TeamRunManifest): string {
return path.join(manifest.stateRoot, "run.lock");
}
function sleepSync(ms: number): void {
try {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
} catch {
// Fallback for environments without SharedArrayBuffer / Atomics.wait support.
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
// Busy-wait — only used as last-resort, retry counts are capped.
}
}
}
function parseCreatedAtFromLock(raw: string): number | undefined {
try {
const payload = JSON.parse(raw) as unknown;
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
const candidate = payload as { createdAt?: unknown };
if (typeof candidate.createdAt !== "string") return undefined;
const parsed = Date.parse(candidate.createdAt);
return Number.isNaN(parsed) ? undefined : parsed;
} catch {
return undefined;
}
}
function isLockStale(filePath: string, staleMs: number): boolean {
try {
const stat = fs.statSync(filePath);
let createdAt = parseCreatedAtFromLock(fs.readFileSync(filePath, "utf-8"));
if (createdAt === undefined) createdAt = stat.mtimeMs;
return Date.now() - createdAt > staleMs;
} catch {
return false;
}
}
function readLockState(filePath: string, staleMs: number): boolean {
if (!isLockStale(filePath, staleMs)) return false;
try {
fs.rmSync(filePath, { force: true });
return true;
} catch {
return false;
}
}
function writeLockFile(filePath: string): void {
const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
try {
fs.writeSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
} finally {
fs.closeSync(fd);
}
}
function acquireLockWithRetry(filePath: string, staleMs: number): void {
let attempt = 0;
const deadline = Date.now() + staleMs * 2;
while (true) {
try {
writeLockFile(filePath);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EEXIST") throw error;
if (!readLockState(filePath, staleMs)) {
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
}
if (Date.now() > deadline) {
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
}
const delay = Math.min(250, 25 * 2 ** attempt);
sleepSync(delay);
attempt++;
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function readLockStateAsync(filePath: string, staleMs: number): void {
try {
if (isLockStale(filePath, staleMs)) fs.rmSync(filePath, { force: true });
} catch {
// Ignore stale-check races.
}
}
async function acquireLockWithRetryAsync(filePath: string, staleMs: number): Promise<void> {
let attempt = 0;
const deadline = Date.now() + staleMs * 2;
while (true) {
try {
writeLockFile(filePath);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EEXIST") throw error;
if (Date.now() > deadline) {
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
}
readLockStateAsync(filePath, staleMs);
const delay = Math.min(250, 25 * 2 ** attempt);
await sleep(delay);
attempt++;
}
}
}
export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, options: RunLockOptions = {}): T {
const filePath = lockPath(manifest);
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
acquireLockWithRetry(filePath, staleMs);
try {
return fn();
} finally {
try {
fs.rmSync(filePath, { force: true });
} catch {
// Best-effort lock cleanup.
}
}
}
export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promise<T>, options: RunLockOptions = {}): Promise<T> {
const filePath = lockPath(manifest);
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
await acquireLockWithRetryAsync(filePath, staleMs);
try {
return await fn();
} finally {
try {
fs.rmSync(filePath, { force: true });
} catch {
// Best-effort lock cleanup.
}
}
}

View File

@@ -0,0 +1,324 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { TeamRunManifest } from "./types.ts";
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
import { redactSecrets } from "../utils/redaction.ts";
export type MailboxDirection = "inbox" | "outbox";
export type MailboxMessageStatus = "queued" | "delivered" | "acknowledged";
export type MailboxMessageKind = "message" | "steer" | "follow-up" | "response" | "group_join";
export type MailboxMessagePriority = "urgent" | "normal" | "low";
export type MailboxDeliveryMode = "interrupt" | "next_turn";
export interface MailboxMessage {
id: string;
runId: string;
direction: MailboxDirection;
from: string;
to: string;
body: string;
createdAt: string;
status: MailboxMessageStatus;
kind?: MailboxMessageKind;
priority?: MailboxMessagePriority;
deliveryMode?: MailboxDeliveryMode;
taskId?: string;
acknowledgedAt?: string;
data?: Record<string, unknown>;
}
export interface MailboxDeliveryState {
messages: Record<string, MailboxMessageStatus>;
updatedAt: string;
}
export interface MailboxValidationIssue {
level: "error" | "warning";
path: string;
message: string;
}
export interface MailboxValidationReport {
issues: MailboxValidationIssue[];
repaired: string[];
}
export interface MailboxReplayResult {
messages: MailboxMessage[];
updatedAt: string;
}
function mailboxDir(manifest: TeamRunManifest): string {
return path.join(manifest.stateRoot, "mailbox");
}
function safeMailboxDir(manifest: TeamRunManifest, create = false): string {
const dir = mailboxDir(manifest);
if (create) fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(dir)) return dir;
if (fs.lstatSync(dir).isSymbolicLink()) throw new Error(`Invalid mailbox directory: ${dir}`);
return resolveRealContainedPath(manifest.stateRoot, "mailbox");
}
function safeTaskId(taskId: string): string {
if (!/^[\w.-]+$/.test(taskId) || taskId.includes("..") || path.isAbsolute(taskId)) throw new Error(`Invalid mailbox task id: ${taskId}`);
return taskId;
}
function safeMailboxTasksRoot(manifest: TeamRunManifest, create = false): string {
const root = path.join(safeMailboxDir(manifest, create), "tasks");
if (create) fs.mkdirSync(root, { recursive: true });
if (!fs.existsSync(root)) return root;
if (fs.lstatSync(root).isSymbolicLink()) throw new Error(`Invalid mailbox tasks directory: ${root}`);
return resolveRealContainedPath(safeMailboxDir(manifest), "tasks");
}
function taskMailboxDir(manifest: TeamRunManifest, taskId: string, create = false): string {
const tasksRoot = safeMailboxTasksRoot(manifest, create);
const normalizedTaskId = safeTaskId(taskId);
const resolved = path.resolve(tasksRoot, normalizedTaskId);
const relative = path.relative(tasksRoot, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Invalid mailbox task id: ${taskId}`);
if (create) fs.mkdirSync(resolved, { recursive: true });
if (!fs.existsSync(resolved)) return resolved;
if (fs.lstatSync(resolved).isSymbolicLink()) throw new Error(`Invalid mailbox task directory: ${resolved}`);
return resolveRealContainedPath(tasksRoot, normalizedTaskId);
}
function mailboxPath(manifest: TeamRunManifest, direction: MailboxDirection, taskId?: string, create = false): string {
return taskId ? path.join(taskMailboxDir(manifest, taskId, create), `${direction}.jsonl`) : path.join(safeMailboxDir(manifest, create), `${direction}.jsonl`);
}
function deliveryPath(manifest: TeamRunManifest, create = false): string {
return path.join(safeMailboxDir(manifest, create), "delivery.json");
}
function safeMailboxFile(filePath: string, parentDir: string): string {
if (!fs.existsSync(filePath)) return filePath;
if (fs.lstatSync(filePath).isSymbolicLink()) throw new Error(`Invalid mailbox file: ${filePath}`);
return resolveRealContainedPath(parentDir, path.basename(filePath));
}
function mailboxFile(manifest: TeamRunManifest, direction: MailboxDirection, taskId?: string, create = false): string {
const parent = taskId ? taskMailboxDir(manifest, taskId, create) : safeMailboxDir(manifest, create);
return safeMailboxFile(path.join(parent, `${direction}.jsonl`), parent);
}
function deliveryFile(manifest: TeamRunManifest, create = false): string {
const parent = safeMailboxDir(manifest, create);
return safeMailboxFile(path.join(parent, "delivery.json"), parent);
}
function ensureRunMailbox(manifest: TeamRunManifest): void {
safeMailboxDir(manifest, true);
for (const direction of ["inbox", "outbox"] as const) {
const filePath = mailboxFile(manifest, direction, undefined, true);
if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, "", "utf-8");
}
const delivery = deliveryFile(manifest, true);
if (!fs.existsSync(delivery)) fs.writeFileSync(delivery, `${JSON.stringify({ messages: {}, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf-8");
}
function ensureTaskMailbox(manifest: TeamRunManifest, taskId: string): void {
ensureRunMailbox(manifest);
taskMailboxDir(manifest, taskId, true);
for (const direction of ["inbox", "outbox"] as const) {
const filePath = mailboxFile(manifest, direction, taskId, true);
if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, "", "utf-8");
}
}
function isDirection(value: unknown): value is MailboxDirection {
return value === "inbox" || value === "outbox";
}
function isStatus(value: unknown): value is MailboxMessageStatus {
return value === "queued" || value === "delivered" || value === "acknowledged";
}
function isKind(value: unknown): value is MailboxMessageKind {
return value === "message" || value === "steer" || value === "follow-up" || value === "response" || value === "group_join";
}
function isPriority(value: unknown): value is MailboxMessagePriority {
return value === "urgent" || value === "normal" || value === "low";
}
function isDeliveryMode(value: unknown): value is MailboxDeliveryMode {
return value === "interrupt" || value === "next_turn";
}
function parseMailboxMessage(raw: unknown, expectedDirection: MailboxDirection): MailboxMessage | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const obj = raw as Record<string, unknown>;
if (typeof obj.id !== "string" || typeof obj.runId !== "string" || !isDirection(obj.direction) || typeof obj.from !== "string" || typeof obj.to !== "string" || typeof obj.body !== "string" || typeof obj.createdAt !== "string" || !isStatus(obj.status)) return undefined;
if (obj.direction !== expectedDirection) return undefined;
const data = obj.data && typeof obj.data === "object" && !Array.isArray(obj.data) ? obj.data as Record<string, unknown> : undefined;
const dataKind = data?.kind;
return { id: obj.id, runId: obj.runId, direction: obj.direction, from: obj.from, to: obj.to, body: obj.body, createdAt: obj.createdAt, status: obj.status, kind: isKind(obj.kind) ? obj.kind : isKind(dataKind) ? dataKind : undefined, priority: isPriority(obj.priority) ? obj.priority : undefined, deliveryMode: isDeliveryMode(obj.deliveryMode) ? obj.deliveryMode : undefined, taskId: typeof obj.taskId === "string" ? obj.taskId : undefined, acknowledgedAt: typeof obj.acknowledgedAt === "string" ? obj.acknowledgedAt : undefined, data };
}
function readMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] {
if (!fs.existsSync(filePath)) return [];
const messages: MailboxMessage[] = [];
const raw = fs.readFileSync(filePath, "utf-8");
for (const line of raw.split(/\r?\n/).filter(Boolean)) {
try {
const message = parseMailboxMessage(JSON.parse(line) as unknown, direction);
if (message) messages.push(message);
} catch {
// Invalid mailbox lines are reported by validateMailbox().
}
}
return messages;
}
function safeReadMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] {
if (!fs.existsSync(filePath)) return [];
return readMailboxFile(filePath, direction);
}
export function readMailbox(manifest: TeamRunManifest, direction?: MailboxDirection, taskId?: string): MailboxMessage[] {
const directions = direction ? [direction] : ["inbox", "outbox"] as const;
return directions.flatMap((item) => safeReadMailboxFile(mailboxFile(manifest, item, taskId), item)).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
function readAllMessages(manifest: TeamRunManifest, direction: MailboxDirection): MailboxMessage[] {
const messages = [...safeReadMailboxFile(mailboxFile(manifest, direction), direction)];
const tasksDir = safeMailboxTasksRoot(manifest);
if (fs.existsSync(tasksDir)) {
for (const entry of fs.readdirSync(tasksDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
messages.push(...safeReadMailboxFile(mailboxFile(manifest, direction, entry.name), direction));
}
}
return messages.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
function readAllInboxMessages(manifest: TeamRunManifest): MailboxMessage[] {
return readAllMessages(manifest, "inbox");
}
export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliveryState {
try {
const raw = JSON.parse(fs.readFileSync(deliveryFile(manifest), "utf-8")) as unknown;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid delivery state.");
const obj = raw as Record<string, unknown>;
const messages: Record<string, MailboxMessageStatus> = {};
if (obj.messages && typeof obj.messages === "object" && !Array.isArray(obj.messages)) {
for (const [id, status] of Object.entries(obj.messages)) if (isStatus(status)) messages[id] = status;
}
return { messages, updatedAt: typeof obj.updatedAt === "string" ? obj.updatedAt : new Date().toISOString() };
} catch {
return { messages: {}, updatedAt: new Date().toISOString() };
}
}
function writeDeliveryState(manifest: TeamRunManifest, state: MailboxDeliveryState): void {
ensureRunMailbox(manifest);
fs.writeFileSync(deliveryFile(manifest, true), `${JSON.stringify(redactSecrets(state), null, 2)}\n`, "utf-8");
}
export function appendMailboxMessage(manifest: TeamRunManifest, message: Omit<MailboxMessage, "id" | "runId" | "createdAt" | "status"> & { id?: string; status?: MailboxMessageStatus }): MailboxMessage {
if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
else ensureRunMailbox(manifest);
const createdAt = new Date().toISOString();
const complete: MailboxMessage = {
id: message.id ?? `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
runId: manifest.runId,
direction: message.direction,
from: message.from,
to: message.to,
body: message.body,
createdAt,
status: message.status ?? "queued",
kind: message.kind,
priority: message.priority,
deliveryMode: message.deliveryMode,
taskId: message.taskId,
data: message.data,
};
fs.appendFileSync(mailboxFile(manifest, complete.direction, complete.taskId), `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
const delivery = readDeliveryState(manifest);
delivery.messages[complete.id] = complete.status;
delivery.updatedAt = createdAt;
writeDeliveryState(manifest, delivery);
return complete;
}
export function appendSteeringMessage(manifest: TeamRunManifest, input: { taskId: string; body: string; from?: string; to?: string; priority?: MailboxMessagePriority; status?: MailboxMessageStatus; data?: Record<string, unknown> }): MailboxMessage {
return appendMailboxMessage(manifest, { direction: "inbox", from: input.from ?? "leader", to: input.to ?? input.taskId, taskId: input.taskId, body: input.body, kind: "steer", priority: input.priority ?? "urgent", deliveryMode: "interrupt", status: input.status, data: { ...(input.data ?? {}), kind: "steer" } });
}
export function appendFollowUpMessage(manifest: TeamRunManifest, input: { taskId: string; body: string; from?: string; to?: string; priority?: MailboxMessagePriority; status?: MailboxMessageStatus; data?: Record<string, unknown> }): MailboxMessage {
return appendMailboxMessage(manifest, { direction: "inbox", from: input.from ?? "leader", to: input.to ?? input.taskId, taskId: input.taskId, body: input.body, kind: "follow-up", priority: input.priority ?? "normal", deliveryMode: "next_turn", status: input.status, data: { ...(input.data ?? {}), kind: "follow-up" } });
}
export function listMailboxByKind(manifest: TeamRunManifest, kind: MailboxMessageKind, direction?: MailboxDirection): MailboxMessage[] {
const messages = direction ? readAllMessages(manifest, direction) : [...readAllMessages(manifest, "inbox"), ...readAllMessages(manifest, "outbox")].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
return messages.filter((message) => message.kind === kind || message.data?.kind === kind);
}
export function findMailboxMessageByRequestId(manifest: TeamRunManifest, requestId: string): MailboxMessage | undefined {
return readMailbox(manifest).find((message) => message.data?.requestId === requestId);
}
export function readMailboxMessage(manifest: TeamRunManifest, messageId: string): MailboxMessage | undefined {
return readMailbox(manifest).find((message) => message.id === messageId);
}
export function acknowledgeMailboxMessage(manifest: TeamRunManifest, messageId: string): MailboxDeliveryState {
const delivery = readDeliveryState(manifest);
if (!delivery.messages[messageId]) throw new Error(`Mailbox message '${messageId}' not found.`);
delivery.messages[messageId] = "acknowledged";
delivery.updatedAt = new Date().toISOString();
writeDeliveryState(manifest, delivery);
return delivery;
}
export function replayPendingMailboxMessages(manifest: TeamRunManifest): MailboxReplayResult {
const delivery = readDeliveryState(manifest);
const pending = readAllInboxMessages(manifest).filter((message) => message.status !== "acknowledged" && delivery.messages[message.id] !== "acknowledged");
if (!pending.length) return { messages: [], updatedAt: delivery.updatedAt };
const updatedAt = new Date().toISOString();
for (const message of pending) delivery.messages[message.id] = "delivered";
delivery.updatedAt = updatedAt;
writeDeliveryState(manifest, delivery);
return { messages: pending, updatedAt };
}
export function validateMailbox(manifest: TeamRunManifest, options: { repair?: boolean } = {}): MailboxValidationReport {
ensureRunMailbox(manifest);
const issues: MailboxValidationIssue[] = [];
const repaired: string[] = [];
for (const direction of ["inbox", "outbox"] as const) {
const filePath = mailboxFile(manifest, direction);
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean);
const validLines: string[] = [];
for (const line of lines) {
try {
const parsed = JSON.parse(line) as unknown;
const message = parseMailboxMessage(parsed, direction);
if (!message) throw new Error("invalid message schema");
validLines.push(JSON.stringify(redactSecrets(message)));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
issues.push({ level: "error", path: filePath, message });
}
}
if (options.repair && validLines.length !== lines.length) {
fs.writeFileSync(filePath, `${validLines.join("\n")}${validLines.length ? "\n" : ""}`, "utf-8");
repaired.push(filePath);
}
}
const delivery = readDeliveryState(manifest);
const allMessages = readMailbox(manifest);
for (const message of allMessages) if (!delivery.messages[message.id]) issues.push({ level: "warning", path: deliveryFile(manifest), message: `Missing delivery entry for ${message.id}.` });
if (options.repair) {
for (const message of allMessages) delivery.messages[message.id] ??= message.status;
delivery.updatedAt = new Date().toISOString();
writeDeliveryState(manifest, delivery);
repaired.push(deliveryFile(manifest));
}
return { issues, repaired };
}

View File

@@ -0,0 +1,321 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { TeamRunManifest, TeamTaskState } from "./types.ts";
import { canTransitionRunStatus } from "./contracts.ts";
import { atomicWriteJson, atomicWriteJsonAsync, readJsonFile } from "./atomic-write.ts";
import { appendEvent } from "./event-log.ts";
import { DEFAULT_CACHE, DEFAULT_PATHS } from "../config/defaults.ts";
import { createRunId, createTaskId } from "../utils/ids.ts";
import { findRepoRoot, projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
import { assertSafePathId, resolveContainedRelativePath, resolveRealContainedPath } from "../utils/safe-paths.ts";
import type { TeamConfig } from "../teams/team-config.ts";
import type { WorkflowConfig } from "../workflows/workflow-config.ts";
export interface RunPaths {
runId: string;
stateRoot: string;
artifactsRoot: string;
manifestPath: string;
tasksPath: string;
eventsPath: string;
}
interface ManifestCacheEntry {
manifest: TeamRunManifest;
tasks: TeamTaskState[];
manifestMtimeMs: number;
manifestSize: number;
tasksMtimeMs: number;
tasksSize: number;
}
const manifestCache = new Map<string, ManifestCacheEntry>();
function setManifestCache(stateRoot: string, entry: ManifestCacheEntry): void {
if (manifestCache.has(stateRoot)) manifestCache.delete(stateRoot);
manifestCache.set(stateRoot, entry);
while (manifestCache.size > DEFAULT_CACHE.manifestMaxEntries) {
const oldest = manifestCache.keys().next().value;
if (!oldest) break;
manifestCache.delete(oldest);
}
}
function useProjectState(cwd: string): boolean {
return findRepoRoot(cwd) !== undefined;
}
function invalidateRunCache(stateRoot: string): void {
manifestCache.delete(stateRoot);
}
function scopeBaseRoot(cwd: string): string {
return useProjectState(cwd) ? projectCrewRoot(cwd) : userCrewRoot();
}
function resolveRunStateRoot(cwd: string, runId: string): string | undefined {
assertSafePathId("runId", runId);
const runsRoot = path.join(scopeBaseRoot(cwd), DEFAULT_PATHS.state.runsSubdir);
const scopedPath = resolveContainedRelativePath(runsRoot, runId, "runId");
if (!fs.existsSync(scopedPath)) return undefined;
try {
if (fs.lstatSync(scopedPath).isSymbolicLink()) return undefined;
resolveRealContainedPath(runsRoot, runId);
} catch {
return undefined;
}
return scopedPath;
}
function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunManifest, stateRoot: string, tasksPath: string): boolean {
if (manifest.runId !== runId || manifest.stateRoot !== stateRoot || manifest.tasksPath !== tasksPath || manifest.eventsPath !== path.join(stateRoot, "events.jsonl")) return false;
const artifactsParent = path.join(scopeBaseRoot(cwd), DEFAULT_PATHS.state.artifactsSubdir);
const expectedArtifactsRoot = resolveContainedRelativePath(artifactsParent, runId, "runId");
if (manifest.artifactsRoot !== expectedArtifactsRoot) return false;
if (fs.existsSync(expectedArtifactsRoot)) {
try {
if (fs.lstatSync(expectedArtifactsRoot).isSymbolicLink()) return false;
resolveRealContainedPath(artifactsParent, runId);
} catch {
return false;
}
}
return true;
}
export function createRunPaths(cwd: string, runId = createRunId()): RunPaths {
assertSafePathId("runId", runId);
const baseRoot = scopeBaseRoot(cwd);
const stateRoot = resolveContainedRelativePath(path.join(baseRoot, DEFAULT_PATHS.state.runsSubdir), runId, "runId");
const artifactsRoot = resolveContainedRelativePath(path.join(baseRoot, DEFAULT_PATHS.state.artifactsSubdir), runId, "runId");
return {
runId,
stateRoot,
artifactsRoot,
manifestPath: path.join(stateRoot, DEFAULT_PATHS.state.manifestFile),
tasksPath: path.join(stateRoot, DEFAULT_PATHS.state.tasksFile),
eventsPath: path.join(stateRoot, DEFAULT_PATHS.state.eventsFile),
};
}
export function createTasksFromWorkflow(runId: string, workflow: WorkflowConfig, team: TeamConfig, cwd: string): TeamTaskState[] {
const stepToTaskId = new Map(workflow.steps.map((step, index) => [step.id, createTaskId(step.id, index)]));
return workflow.steps.map((step, index) => {
const role = team.roles.find((candidate) => candidate.name === step.role);
const id = stepToTaskId.get(step.id) ?? createTaskId(step.id, index);
const dependencies = step.dependsOn ?? [];
const children = workflow.steps.filter((candidate) => candidate.dependsOn?.includes(step.id)).map((candidate) => stepToTaskId.get(candidate.id)).filter((childId): childId is string => childId !== undefined);
return {
id,
runId,
stepId: step.id,
role: step.role,
agent: role?.agent ?? step.role,
title: step.id,
status: "queued",
dependsOn: dependencies,
cwd,
model: step.model,
graph: {
taskId: id,
parentId: dependencies[0] ? stepToTaskId.get(dependencies[0]) : undefined,
children,
dependencies: dependencies.map((dep) => stepToTaskId.get(dep) ?? dep),
queue: dependencies.length ? "blocked" : "ready",
},
};
});
}
export function createRunManifest(params: {
cwd: string;
team: TeamConfig;
workflow?: WorkflowConfig;
goal: string;
workspaceMode?: "single" | "worktree";
ownerSessionId?: string;
}): { manifest: TeamRunManifest; tasks: TeamTaskState[]; paths: RunPaths } {
const paths = createRunPaths(params.cwd);
const now = new Date().toISOString();
const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd) : [];
const manifest: TeamRunManifest = {
schemaVersion: 1,
runId: paths.runId,
team: params.team.name,
workflow: params.workflow?.name,
goal: params.goal,
status: "queued",
workspaceMode: params.workspaceMode ?? params.team.workspaceMode ?? "single",
createdAt: now,
updatedAt: now,
cwd: params.cwd,
stateRoot: paths.stateRoot,
artifactsRoot: paths.artifactsRoot,
tasksPath: paths.tasksPath,
eventsPath: paths.eventsPath,
artifacts: [],
...(params.ownerSessionId ? { ownerSessionId: params.ownerSessionId } : {}),
};
fs.mkdirSync(paths.stateRoot, { recursive: true });
fs.mkdirSync(paths.artifactsRoot, { recursive: true });
atomicWriteJson(paths.manifestPath, manifest);
atomicWriteJson(paths.tasksPath, tasks);
appendEvent(paths.eventsPath, {
type: "run.created",
runId: paths.runId,
data: { team: params.team.name, workflow: params.workflow?.name },
metadata: {
seq: 1,
provenance: "team_runner",
sessionIdentity: { title: params.team.name, workspace: params.cwd, purpose: params.goal },
ownership: { owner: params.team.name, workflowScope: params.workflow?.name ?? "manual", watcherAction: "act" },
confidence: "high",
},
});
invalidateRunCache(paths.stateRoot);
return { manifest, tasks, paths };
}
export function saveRunManifest(manifest: TeamRunManifest): void {
atomicWriteJson(path.join(manifest.stateRoot, "manifest.json"), manifest);
invalidateRunCache(manifest.stateRoot);
}
export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise<void> {
await atomicWriteJsonAsync(path.join(manifest.stateRoot, "manifest.json"), manifest);
invalidateRunCache(manifest.stateRoot);
}
export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
atomicWriteJson(manifest.tasksPath, tasks);
invalidateRunCache(manifest.stateRoot);
}
export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
await atomicWriteJsonAsync(manifest.tasksPath, tasks);
invalidateRunCache(manifest.stateRoot);
}
export interface UpdateRunStatusOptions {
data?: Record<string, unknown>;
metadata?: Parameters<typeof appendEvent>[1]["metadata"];
}
export function updateRunStatus(manifest: TeamRunManifest, status: TeamRunManifest["status"], summary?: string, options: UpdateRunStatusOptions = {}): TeamRunManifest {
if (!canTransitionRunStatus(manifest.status, status)) {
throw new Error(`Invalid run status transition: ${manifest.status} -> ${status}`);
}
const updated: TeamRunManifest = { ...manifest, status, updatedAt: new Date().toISOString(), summary: summary ?? manifest.summary };
saveRunManifest(updated);
appendEvent(updated.eventsPath, {
type: `run.${status}`,
runId: updated.runId,
message: summary,
...(options.data ? { data: options.data } : {}),
metadata: {
provenance: "team_runner",
sessionIdentity: { title: updated.team, workspace: updated.cwd, purpose: updated.goal },
ownership: { owner: updated.team, workflowScope: updated.workflow ?? "manual", watcherAction: "act" },
confidence: "high",
...options.metadata,
},
});
return updated;
}
export function __test__manifestCacheSize(): number {
return manifestCache.size;
}
export function __test__clearManifestCache(): void {
manifestCache.clear();
}
async function readJsonFileAsync<T>(filePath: string): Promise<T | undefined> {
try {
return JSON.parse(await fs.promises.readFile(filePath, "utf-8")) as T;
} catch {
return undefined;
}
}
export function loadRunManifestById(cwd: string, runId: string): { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined {
const stateRoot = resolveRunStateRoot(cwd, runId);
if (!stateRoot) return undefined;
const manifestPath = path.join(stateRoot, "manifest.json");
const tasksPath = path.join(stateRoot, "tasks.json");
let manifestStat: fs.Stats;
try {
manifestStat = fs.statSync(manifestPath);
} catch {
return undefined;
}
const cached = manifestCache.get(stateRoot);
let tasksStat: fs.Stats | undefined;
try {
tasksStat = fs.statSync(tasksPath);
} catch {
tasksStat = undefined;
}
const tasksMtimeMs = tasksStat?.mtimeMs ?? 0;
if (
cached
&& cached.manifestMtimeMs === manifestStat.mtimeMs
&& cached.manifestSize === manifestStat.size
&& cached.tasksMtimeMs === tasksMtimeMs
&& cached.tasksSize === (tasksStat?.size ?? 0)
) {
if (!validateRunManifestPaths(cwd, runId, cached.manifest, stateRoot, tasksPath)) {
manifestCache.delete(stateRoot);
return undefined;
}
return { manifest: cached.manifest, tasks: cached.tasks };
}
const manifest = readJsonFile<TeamRunManifest>(manifestPath);
if (!manifest || !validateRunManifestPaths(cwd, runId, manifest, stateRoot, tasksPath)) return undefined;
const tasks = readJsonFile<TeamTaskState[]>(tasksPath) ?? [];
setManifestCache(stateRoot, {
manifest,
tasks,
manifestMtimeMs: manifestStat.mtimeMs,
manifestSize: manifestStat.size,
tasksMtimeMs,
tasksSize: tasksStat?.size ?? 0,
});
return { manifest, tasks };
}
export async function loadRunManifestByIdAsync(cwd: string, runId: string): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined> {
const stateRoot = resolveRunStateRoot(cwd, runId);
if (!stateRoot) return undefined;
const manifestPath = path.join(stateRoot, "manifest.json");
const tasksPath = path.join(stateRoot, "tasks.json");
let manifestStat: fs.Stats;
try {
manifestStat = await fs.promises.stat(manifestPath);
} catch {
return undefined;
}
const cached = manifestCache.get(stateRoot);
let tasksStat: fs.Stats | undefined;
try {
tasksStat = await fs.promises.stat(tasksPath);
} catch {
tasksStat = undefined;
}
const tasksMtimeMs = tasksStat?.mtimeMs ?? 0;
if (cached && cached.manifestMtimeMs === manifestStat.mtimeMs && cached.manifestSize === manifestStat.size && cached.tasksMtimeMs === tasksMtimeMs && cached.tasksSize === (tasksStat?.size ?? 0)) {
if (!validateRunManifestPaths(cwd, runId, cached.manifest, stateRoot, tasksPath)) {
manifestCache.delete(stateRoot);
return undefined;
}
return { manifest: cached.manifest, tasks: cached.tasks };
}
const manifest = await readJsonFileAsync<TeamRunManifest>(manifestPath);
if (!manifest || !validateRunManifestPaths(cwd, runId, manifest, stateRoot, tasksPath)) return undefined;
const tasks = await readJsonFileAsync<TeamTaskState[]>(tasksPath) ?? [];
setManifestCache(stateRoot, { manifest, tasks, manifestMtimeMs: manifestStat.mtimeMs, manifestSize: manifestStat.size, tasksMtimeMs, tasksSize: tasksStat?.size ?? 0 });
return { manifest, tasks };
}

View File

@@ -0,0 +1,44 @@
import { randomUUID } from "node:crypto";
import type { TeamTaskState } from "./types.ts";
export interface TaskClaimState {
owner: string;
token: string;
leasedUntil: string;
}
export function createTaskClaim(owner: string, leaseMs = 5 * 60_000, now = new Date()): TaskClaimState {
return { owner, token: randomUUID(), leasedUntil: new Date(now.getTime() + leaseMs).toISOString() };
}
export function isTaskClaimExpired(claim: TaskClaimState | undefined, now = new Date()): boolean {
if (!claim) return false;
const parsed = Date.parse(claim.leasedUntil);
// Corrupt or invalid date strings produce NaN — treat as expired immediately.
return Number.isFinite(parsed) ? parsed <= now.getTime() : true;
}
export function canUseTaskClaim(task: Pick<TeamTaskState, "claim">, owner: string, token: string, now = new Date()): boolean {
return task.claim?.owner === owner && task.claim.token === token && !isTaskClaimExpired(task.claim, now);
}
export function claimTask<T extends TeamTaskState>(task: T, owner: string, leaseMs?: number, now = new Date()): T {
if (task.claim && !isTaskClaimExpired(task.claim, now)) {
throw new Error(`Task '${task.id}' is already claimed by '${task.claim.owner}'.`);
}
return { ...task, claim: createTaskClaim(owner, leaseMs, now) };
}
export function releaseTaskClaim<T extends TeamTaskState>(task: T, owner: string, token: string, now = new Date()): T {
if (!canUseTaskClaim(task, owner, token, now)) {
throw new Error(`Task '${task.id}' claim is not held by '${owner}' or has expired.`);
}
return { ...task, claim: undefined };
}
export function transitionClaimedTaskStatus<T extends TeamTaskState>(task: T, owner: string, token: string, status: T["status"], now = new Date()): T {
if (!canUseTaskClaim(task, owner, token, now)) {
throw new Error(`Task '${task.id}' claim is not held by '${owner}' or has expired.`);
}
return { ...task, status };
}

View File

@@ -0,0 +1,256 @@
import type { TeamRunStatus, TeamTaskStatus } from "./contracts.ts";
import type { TaskClaimState } from "./task-claims.ts";
import type { WorkerHeartbeatState } from "../runtime/worker-heartbeat.ts";
import type { CrewAgentProgress } from "../runtime/crew-agent-runtime.ts";
export type { TeamRunStatus, TeamTaskStatus } from "./contracts.ts";
export interface ArtifactDescriptor {
kind: "plan" | "prompt" | "result" | "summary" | "log" | "diff" | "patch" | "progress" | "notepad" | "metadata";
path: string;
createdAt: string;
producer: string;
sizeBytes?: number;
contentHash?: string;
retention: "run" | "project" | "temporary";
expiresAt?: string;
}
export type TaskScope = "workspace" | "module" | "single_file" | "custom";
export type GreenLevel = "none" | "targeted" | "package" | "workspace" | "merge_ready";
export interface VerificationCommandResult {
cmd: string;
status: "passed" | "failed" | "not_run";
exitCode?: number | null;
outputArtifact?: ArtifactDescriptor;
}
export interface VerificationContract {
requiredGreenLevel: GreenLevel;
commands: string[];
allowManualEvidence: boolean;
}
export interface VerificationEvidence {
requiredGreenLevel: GreenLevel;
observedGreenLevel: GreenLevel;
satisfied: boolean;
commands: VerificationCommandResult[];
notes?: string;
}
export interface TaskPacket {
objective: string;
scope: TaskScope;
scopePath?: string;
repo: string;
worktree?: string;
branchPolicy: string;
acceptanceTests: string[];
commitPolicy: string;
reportingContract: string;
escalationPolicy: string;
constraints: string[];
expectedArtifacts: string[];
verification: VerificationContract;
}
export type PolicyDecisionAction = "retry" | "reassign" | "escalate" | "block" | "notify" | "cleanup" | "closeout" | "fail";
export type PolicyDecisionReason = "task_failed" | "worker_stale" | "green_unsatisfied" | "limit_exceeded" | "run_complete" | "mailbox_timeout" | "review_rejected" | "branch_stale" | "scope_mismatch" | "ineffective_worker";
export interface PolicyDecision {
action: PolicyDecisionAction;
reason: PolicyDecisionReason;
message: string;
taskId?: string;
createdAt: string;
}
export interface TaskGraphNode {
taskId: string;
parentId?: string;
children: string[];
dependencies: string[];
queue: "ready" | "blocked" | "running" | "done";
sessionForkFrom?: string;
}
export interface AsyncRunState {
pid?: number;
logPath: string;
spawnedAt: string;
}
export interface RuntimeResolutionState {
kind: "scaffold" | "child-process" | "live-session";
requestedMode: "auto" | "scaffold" | "child-process" | "live-session";
safety: "trusted" | "explicit_dry_run" | "blocked";
available: boolean;
fallback?: "scaffold" | "child-process" | "live-session";
reason?: string;
resolvedAt: string;
}
export interface WorkerExitStatus {
exitCode: number | null;
cancelled: boolean;
timedOut: boolean;
killed: boolean;
signal?: string;
cleanupErrors: string[];
finalDrainMs: number;
}
export interface OperationTerminalEvidence {
operation: "worker" | "tool" | "model";
status: "cancelled" | "failed" | "completed";
startedAt?: string;
finishedAt: string;
attemptId?: string;
reason?: {
code: string;
message: string;
};
exitStatus?: WorkerExitStatus;
}
export interface PlanApprovalState {
required: boolean;
status: "pending" | "approved" | "cancelled";
requestedAt: string;
updatedAt: string;
approvedAt?: string;
cancelledAt?: string;
planTaskId?: string;
planArtifactPath?: string;
}
export type CrewActivityState = "active" | "active_long_running" | "needs_attention" | "stale";
export type CrewAttentionReason = "idle" | "tool_failures" | "completion_guard" | "heartbeat_stale" | "plan_approval_pending";
export interface CrewAttentionEventData {
activityState: CrewActivityState;
reason: CrewAttentionReason;
elapsedMs?: number;
taskId?: string;
agentName?: string;
suggestedAction?: string;
observedTools?: string[];
}
export interface TeamRunManifest {
schemaVersion: 1;
runId: string;
team: string;
workflow?: string;
goal: string;
status: TeamRunStatus;
workspaceMode: "single" | "worktree";
createdAt: string;
updatedAt: string;
cwd: string;
stateRoot: string;
artifactsRoot: string;
tasksPath: string;
eventsPath: string;
artifacts: ArtifactDescriptor[];
async?: AsyncRunState;
planApproval?: PlanApprovalState;
/** Pi session that created the run, when available. Used to prevent cross-session destructive actions. */
ownerSessionId?: string;
/** pi-crew skill override selected when the run was created. false disables injected skill instructions. */
skillOverride?: string[] | false;
/** Resolved runtime/safety mode used for execution. Optional for backward compatibility with older manifests. */
runtimeResolution?: RuntimeResolutionState;
/** Effective run config snapshot used by async background workers. Optional for backward compatibility. */
runConfig?: unknown;
summary?: string;
policyDecisions?: PolicyDecision[];
}
export interface UsageState {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
cost?: number;
turns?: number;
}
export interface ModelAttemptState {
model: string;
success: boolean;
exitCode?: number | null;
error?: string;
}
export interface ModelRoutingState {
requested?: string;
resolved: string;
fallbackChain: string[];
reason?: string;
usedAttempt: number;
}
export interface TaskWorktreeState {
path: string;
branch: string;
reused: boolean;
}
export interface TaskCheckpointState {
phase: "started" | "child-spawned" | "child-stdout-final" | "artifact-written";
updatedAt: string;
childPid?: number;
}
export interface TaskAttemptState {
attemptId?: string;
startedAt: string;
endedAt?: string;
error?: string;
}
export interface TeamTaskState {
id: string;
runId: string;
stepId?: string;
role: string;
agent: string;
title: string;
status: TeamTaskStatus;
dependsOn: string[];
cwd: string;
worktree?: TaskWorktreeState;
promptArtifact?: ArtifactDescriptor;
resultArtifact?: ArtifactDescriptor;
logArtifact?: ArtifactDescriptor;
transcriptArtifact?: ArtifactDescriptor;
startedAt?: string;
finishedAt?: string;
exitCode?: number | null;
model?: string;
modelAttempts?: ModelAttemptState[];
modelRouting?: ModelRoutingState;
usage?: UsageState;
jsonEvents?: number;
agentProgress?: CrewAgentProgress;
error?: string;
claim?: TaskClaimState;
heartbeat?: WorkerHeartbeatState;
checkpoint?: TaskCheckpointState;
attempts?: TaskAttemptState[];
workerExitStatus?: WorkerExitStatus;
terminalEvidence?: OperationTerminalEvidence[];
taskPacket?: TaskPacket;
verification?: VerificationEvidence;
graph?: TaskGraphNode;
adaptive?: {
phase: string;
task: string;
};
policy?: {
retryCount?: number;
lastDecision?: PolicyDecision;
};
}

View File

@@ -0,0 +1,29 @@
import type { TeamTaskState, UsageState } from "./types.ts";
export function aggregateUsage(tasks: TeamTaskState[]): UsageState | undefined {
const total: UsageState = {};
let found = false;
for (const task of tasks) {
if (!task.usage) continue;
found = true;
total.input = (total.input ?? 0) + (task.usage.input ?? 0);
total.output = (total.output ?? 0) + (task.usage.output ?? 0);
total.cacheRead = (total.cacheRead ?? 0) + (task.usage.cacheRead ?? 0);
total.cacheWrite = (total.cacheWrite ?? 0) + (task.usage.cacheWrite ?? 0);
total.cost = (total.cost ?? 0) + (task.usage.cost ?? 0);
total.turns = (total.turns ?? 0) + (task.usage.turns ?? 0);
}
return found ? total : undefined;
}
export function formatUsage(usage: UsageState | undefined): string {
if (!usage) return "(none)";
const parts: string[] = [];
if (usage.input !== undefined) parts.push(`input=${usage.input}`);
if (usage.output !== undefined) parts.push(`output=${usage.output}`);
if (usage.cacheRead !== undefined) parts.push(`cacheRead=${usage.cacheRead}`);
if (usage.cacheWrite !== undefined) parts.push(`cacheWrite=${usage.cacheWrite}`);
if (usage.cost !== undefined && Number.isFinite(usage.cost)) parts.push(`cost=${usage.cost.toFixed(6)}`);
if (usage.turns !== undefined) parts.push(`turns=${usage.turns}`);
return parts.join(", ") || "(none)";
}