Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom
This commit is contained in:
165
extensions/pi-crew/src/state/active-run-registry.ts
Normal file
165
extensions/pi-crew/src/state/active-run-registry.ts
Normal 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)))];
|
||||
}
|
||||
126
extensions/pi-crew/src/state/artifact-store.ts
Normal file
126
extensions/pi-crew/src/state/artifact-store.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
122
extensions/pi-crew/src/state/atomic-write.ts
Normal file
122
extensions/pi-crew/src/state/atomic-write.ts
Normal 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;
|
||||
}
|
||||
109
extensions/pi-crew/src/state/contracts.ts
Normal file
109
extensions/pi-crew/src/state/contracts.ts
Normal 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);
|
||||
}
|
||||
190
extensions/pi-crew/src/state/event-log.ts
Normal file
190
extensions/pi-crew/src/state/event-log.ts
Normal 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;
|
||||
}
|
||||
82
extensions/pi-crew/src/state/jsonl-writer.ts
Normal file
82
extensions/pi-crew/src/state/jsonl-writer.ts
Normal 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()));
|
||||
},
|
||||
};
|
||||
}
|
||||
157
extensions/pi-crew/src/state/locks.ts
Normal file
157
extensions/pi-crew/src/state/locks.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
324
extensions/pi-crew/src/state/mailbox.ts
Normal file
324
extensions/pi-crew/src/state/mailbox.ts
Normal 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 };
|
||||
}
|
||||
321
extensions/pi-crew/src/state/state-store.ts
Normal file
321
extensions/pi-crew/src/state/state-store.ts
Normal 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 };
|
||||
}
|
||||
44
extensions/pi-crew/src/state/task-claims.ts
Normal file
44
extensions/pi-crew/src/state/task-claims.ts
Normal 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 };
|
||||
}
|
||||
256
extensions/pi-crew/src/state/types.ts
Normal file
256
extensions/pi-crew/src/state/types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
29
extensions/pi-crew/src/state/usage.ts
Normal file
29
extensions/pi-crew/src/state/usage.ts
Normal 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)";
|
||||
}
|
||||
Reference in New Issue
Block a user