src / agents / runStore.ts
src / agents / runStore.ts
import { AgenticError } from "../core/errors";
import { createId } from "../core/id";
import type { InternalStorage } from "../core/internalStorage";
export type AgentRunStatus =
| "queued"
| "running"
| "completed"
| "failed"
| "canceled"
| "orphaned";
export type AgentRunMode = "coding" | "research" | "general";
export interface AgentSourceRef {
id: string;
url: string;
title?: string;
contentPath: string;
sha256: string;
}
export interface AgentPlanItem {
id: string;
text: string;
status: "pending" | "in_progress" | "completed" | "blocked";
}
export interface AgentEvent {
id: string;
timestamp: string;
type:
| "run_started"
| "pass_started"
| "pass_completed"
| "tool"
| "plan"
| "note"
| "finish"
| "error"
| "canceled";
summary: string;
details?: Record<string, unknown>;
}
export interface AgentFinal {
summary: string;
evidence: string[];
remaining: string[];
confidence: number;
}
export interface AgentRunState {
version: 1;
id: string;
specHash: string;
idempotencyKey?: string;
status: AgentRunStatus;
objective: string;
context?: string;
role: string;
modelId?: string;
mode: AgentRunMode;
/** Whether this run may commit its own transactions, or must leave them planned for the user. */
commitEdits: boolean;
allowCommands: boolean;
allowWeb: boolean;
taskBoardId?: string;
researchProjectId?: string;
createdAt: string;
updatedAt: string;
startedAt?: string;
finishedAt?: string;
pass: number;
maxPasses: number;
roundsPerPass: number;
toolCalls: number;
maxToolCalls: number;
plan: AgentPlanItem[];
filesRead: Array<{ path: string; sha256: string }>;
filesChanged: string[];
transactions: string[];
commands: Array<{ id: string; status: string; summary: string }>;
sources: AgentSourceRef[];
notes: string[];
recentEvents: AgentEvent[];
final?: AgentFinal;
error?: string;
}
const agentRunLocks = new Map<string, Promise<void>>();
function validateRunId(id: string): void {
if (!/^run_[a-z0-9_]+$/i.test(id)) {
throw new AgenticError("INVALID_INPUT", `Invalid agent run id: ${id}`);
}
}
export class AgentRunStore {
private readonly runsRelative: string;
public constructor(private readonly storage: InternalStorage) {
this.runsRelative = storage.relative("runs");
}
public async initialize(): Promise<void> {
await this.storage.ensureDirectory(this.runsRelative);
}
public async create(state: AgentRunState): Promise<void> {
await this.initialize();
await this.withLock(state.id, async () => {
await this.storage.ensureDirectory(this.runRelative(state.id));
await this.saveUnlocked(state);
});
}
public async load(id: string): Promise<AgentRunState> {
validateRunId(id);
try {
return await this.storage.readJson<AgentRunState>(this.stateRelative(id));
} catch (error) {
if (error instanceof AgenticError && error.code === "NOT_FOUND") {
throw new AgenticError("NOT_FOUND", `Agent run not found: ${id}`);
}
throw error;
}
}
public async save(state: AgentRunState): Promise<void> {
await this.withLock(state.id, async () => {
await this.saveUnlocked(state);
});
}
public async event(
state: AgentRunState,
event: Omit<AgentEvent, "id" | "timestamp">,
recentLimit = 30,
): Promise<AgentEvent> {
const complete: AgentEvent = {
id: createId("ae"),
timestamp: new Date().toISOString(),
...event,
};
await this.withLock(state.id, async () => {
await this.storage.appendJsonLine(this.eventsRelative(state.id), complete);
state.recentEvents = [...state.recentEvents, complete].slice(-recentLimit);
await this.saveUnlocked(state);
});
return complete;
}
public async appendTranscript(
state: AgentRunState,
entry: Record<string, unknown>,
): Promise<void> {
await this.withLock(state.id, async () => {
await this.storage.appendJsonLine(this.transcriptRelativePath(state.id), {
timestamp: new Date().toISOString(),
...entry,
});
});
}
public async history(
id: string,
limit = 50,
): Promise<{ events: AgentEvent[]; total: number }> {
validateRunId(id);
try {
const content = await this.storage.readText(this.eventsRelative(id));
const events = content
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line) as AgentEvent);
return {
events: events.slice(-Math.max(1, Math.min(limit, 500))),
total: events.length,
};
} catch (error) {
if (error instanceof AgenticError && error.code === "NOT_FOUND") {
await this.load(id);
return { events: [], total: 0 };
}
throw error;
}
}
public async list(limit = 20): Promise<AgentRunState[]> {
await this.initialize();
const entries = await this.storage.readDirectory(this.runsRelative, {
withFileTypes: true,
});
const states: AgentRunState[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith("run_")) continue;
try {
states.push(await this.load(entry.name));
} catch {
// Keep incomplete directories available for manual inspection.
}
}
return states
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, Math.max(1, Math.min(limit, 100)));
}
public stateRelativePath(id: string): string {
validateRunId(id);
return this.stateRelative(id);
}
public transcriptRelativePath(id: string): string {
validateRunId(id);
return `${this.runRelative(id)}/transcript.ndjson`;
}
private async saveUnlocked(state: AgentRunState): Promise<void> {
validateRunId(state.id);
state.updatedAt = new Date().toISOString();
await this.storage.writeJson(this.stateRelative(state.id), state);
}
private async withLock<T>(id: string, action: () => Promise<T>): Promise<T> {
validateRunId(id);
const key = `${this.storage.boundary.realRoot}\0${id}`;
const previous = agentRunLocks.get(key) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
agentRunLocks.set(key, queued);
await previous;
try {
return await action();
} finally {
release();
if (agentRunLocks.get(key) === queued) agentRunLocks.delete(key);
}
}
private runRelative(id: string): string {
validateRunId(id);
return `${this.runsRelative}/${id}`;
}
private stateRelative(id: string): string {
return `${this.runRelative(id)}/state.json`;
}
private eventsRelative(id: string): string {
return `${this.runRelative(id)}/events.ndjson`;
}
}
import { AgenticError } from "../core/errors";
import { createId } from "../core/id";
import type { InternalStorage } from "../core/internalStorage";
export type AgentRunStatus =
| "queued"
| "running"
| "completed"
| "failed"
| "canceled"
| "orphaned";
export type AgentRunMode = "coding" | "research" | "general";
export interface AgentSourceRef {
id: string;
url: string;
title?: string;
contentPath: string;
sha256: string;
}
export interface AgentPlanItem {
id: string;
text: string;
status: "pending" | "in_progress" | "completed" | "blocked";
}
export interface AgentEvent {
id: string;
timestamp: string;
type:
| "run_started"
| "pass_started"
| "pass_completed"
| "tool"
| "plan"
| "note"
| "finish"
| "error"
| "canceled";
summary: string;
details?: Record<string, unknown>;
}
export interface AgentFinal {
summary: string;
evidence: string[];
remaining: string[];
confidence: number;
}
export interface AgentRunState {
version: 1;
id: string;
specHash: string;
idempotencyKey?: string;
status: AgentRunStatus;
objective: string;
context?: string;
role: string;
modelId?: string;
mode: AgentRunMode;
/** Whether this run may commit its own transactions, or must leave them planned for the user. */
commitEdits: boolean;
allowCommands: boolean;
allowWeb: boolean;
taskBoardId?: string;
researchProjectId?: string;
createdAt: string;
updatedAt: string;
startedAt?: string;
finishedAt?: string;
pass: number;
maxPasses: number;
roundsPerPass: number;
toolCalls: number;
maxToolCalls: number;
plan: AgentPlanItem[];
filesRead: Array<{ path: string; sha256: string }>;
filesChanged: string[];
transactions: string[];
commands: Array<{ id: string; status: string; summary: string }>;
sources: AgentSourceRef[];
notes: string[];
recentEvents: AgentEvent[];
final?: AgentFinal;
error?: string;
}
const agentRunLocks = new Map<string, Promise<void>>();
function validateRunId(id: string): void {
if (!/^run_[a-z0-9_]+$/i.test(id)) {
throw new AgenticError("INVALID_INPUT", `Invalid agent run id: ${id}`);
}
}
export class AgentRunStore {
private readonly runsRelative: string;
public constructor(private readonly storage: InternalStorage) {
this.runsRelative = storage.relative("runs");
}
public async initialize(): Promise<void> {
await this.storage.ensureDirectory(this.runsRelative);
}
public async create(state: AgentRunState): Promise<void> {
await this.initialize();
await this.withLock(state.id, async () => {
await this.storage.ensureDirectory(this.runRelative(state.id));
await this.saveUnlocked(state);
});
}
public async load(id: string): Promise<AgentRunState> {
validateRunId(id);
try {
return await this.storage.readJson<AgentRunState>(this.stateRelative(id));
} catch (error) {
if (error instanceof AgenticError && error.code === "NOT_FOUND") {
throw new AgenticError("NOT_FOUND", `Agent run not found: ${id}`);
}
throw error;
}
}
public async save(state: AgentRunState): Promise<void> {
await this.withLock(state.id, async () => {
await this.saveUnlocked(state);
});
}
public async event(
state: AgentRunState,
event: Omit<AgentEvent, "id" | "timestamp">,
recentLimit = 30,
): Promise<AgentEvent> {
const complete: AgentEvent = {
id: createId("ae"),
timestamp: new Date().toISOString(),
...event,
};
await this.withLock(state.id, async () => {
await this.storage.appendJsonLine(this.eventsRelative(state.id), complete);
state.recentEvents = [...state.recentEvents, complete].slice(-recentLimit);
await this.saveUnlocked(state);
});
return complete;
}
public async appendTranscript(
state: AgentRunState,
entry: Record<string, unknown>,
): Promise<void> {
await this.withLock(state.id, async () => {
await this.storage.appendJsonLine(this.transcriptRelativePath(state.id), {
timestamp: new Date().toISOString(),
...entry,
});
});
}
public async history(
id: string,
limit = 50,
): Promise<{ events: AgentEvent[]; total: number }> {
validateRunId(id);
try {
const content = await this.storage.readText(this.eventsRelative(id));
const events = content
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line) as AgentEvent);
return {
events: events.slice(-Math.max(1, Math.min(limit, 500))),
total: events.length,
};
} catch (error) {
if (error instanceof AgenticError && error.code === "NOT_FOUND") {
await this.load(id);
return { events: [], total: 0 };
}
throw error;
}
}
public async list(limit = 20): Promise<AgentRunState[]> {
await this.initialize();
const entries = await this.storage.readDirectory(this.runsRelative, {
withFileTypes: true,
});
const states: AgentRunState[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith("run_")) continue;
try {
states.push(await this.load(entry.name));
} catch {
// Keep incomplete directories available for manual inspection.
}
}
return states
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, Math.max(1, Math.min(limit, 100)));
}
public stateRelativePath(id: string): string {
validateRunId(id);
return this.stateRelative(id);
}
public transcriptRelativePath(id: string): string {
validateRunId(id);
return `${this.runRelative(id)}/transcript.ndjson`;
}
private async saveUnlocked(state: AgentRunState): Promise<void> {
validateRunId(state.id);
state.updatedAt = new Date().toISOString();
await this.storage.writeJson(this.stateRelative(state.id), state);
}
private async withLock<T>(id: string, action: () => Promise<T>): Promise<T> {
validateRunId(id);
const key = `${this.storage.boundary.realRoot}\0${id}`;
const previous = agentRunLocks.get(key) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
agentRunLocks.set(key, queued);
await previous;
try {
return await action();
} finally {
release();
if (agentRunLocks.get(key) === queued) agentRunLocks.delete(key);
}
}
private runRelative(id: string): string {
validateRunId(id);
return `${this.runsRelative}/${id}`;
}
private stateRelative(id: string): string {
return `${this.runRelative(id)}/state.json`;
}
private eventsRelative(id: string): string {
return `${this.runRelative(id)}/events.ndjson`;
}
}