feat: add minimal run engine

This commit is contained in:
chungyeong
2026-05-11 00:46:45 +09:00
parent 64efeabd33
commit 78ebd5ef78
26 changed files with 6045 additions and 209 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -136,6 +136,18 @@ class StartFailsFakeAdapter extends FakeSessionAdapter {
}
}
class ResumeFailsFakeAdapter extends FakeSessionAdapter {
resumeAttempts = 0;
override async resume(_handle: SessionHandle): Promise<SessionHandle> {
this.resumeAttempts += 1;
throw new DevflowError("transient resume failure", {
class: "recoverable",
code: "pane_briefly_unresponsive",
});
}
}
class PromptWritesArtifactBeforeReturnFakeAdapter extends FakeSessionAdapter {
override async sendPrompt(
handle: SessionHandle,
@@ -334,7 +346,7 @@ describe("runSingleFakePhase", () => {
worktreeRoot,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 100 },
}),
).rejects.toMatchObject({ code: "internal_state_corruption" });
).rejects.toMatchObject({ code: "run_state_changed" });
const [run] = await db.select({ state: runs.state }).from(runs).where(eq(runs.id, runId));
expect(run).toEqual({ state: runState });
@@ -578,6 +590,209 @@ describe("runSingleFakePhase", () => {
expect(approvals).toEqual([]);
});
it("moves a successful workflow-gated phase from busy to waiting for approval without an idle event", async () => {
const { db, phaseId, runId } = await createRunAndPhase();
const worktreeRoot = realpathSync(
mkdtempSync(join(tmpdir(), "devflow-fake-phase-workflow-gate-")),
);
tempRoots.push(worktreeRoot);
const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json");
const sessionId = randomUUID();
await runSingleFakePhase({
adapter: new FakeSessionAdapter({ sessionIdFactory: () => sessionId, writeDelayMs: 0 }),
db,
expectedArtifactPath,
expectedSchema: "dev/spec@1",
instructions: "Scenario: ok\nWrite the development specification.",
phaseId,
phaseKey: "implement",
roleId: "implementer",
runId,
worktreeRoot,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
workflowApprovalGateKey: "spec_approved",
uuidFactory: () => "00000000-0000-4000-8000-000000000024",
});
const [run] = await db
.select({ currentPhaseId: runs.currentPhaseId, state: runs.state })
.from(runs)
.where(eq(runs.id, runId));
expect(run).toEqual({ currentPhaseId: phaseId, state: "awaiting_approval" });
const [phase] = await db
.select({ state: runPhases.state })
.from(runPhases)
.where(eq(runPhases.id, phaseId));
expect(phase).toEqual({ state: "awaiting_approval" });
const [session] = await db
.select({ state: tuiSessions.state })
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionId));
expect(session).toEqual({ state: "WAITING_FOR_APPROVAL" });
const [approval] = await db
.select({ gateKey: approvalRequests.gateKey, state: approvalRequests.state })
.from(approvalRequests)
.where(eq(approvalRequests.runId, runId));
expect(approval).toEqual({ gateKey: "spec_approved", state: "pending" });
const events = await db
.select({ type: runEvents.type })
.from(runEvents)
.where(eq(runEvents.runId, runId))
.orderBy(runEvents.seq);
expect(events.map((event) => event.type)).toEqual([
"phase.started",
"session.created",
"session.ready",
"session.busy",
"prompt.sent",
"artifact.expected",
"artifact.validated",
"approval.requested",
]);
});
it("does not mark a timeout-repaired workflow-gated phase idle before approval", async () => {
const { db, phaseId, runId } = await createRunAndPhase();
const worktreeRoot = realpathSync(
mkdtempSync(join(tmpdir(), "devflow-fake-phase-workflow-timeout-repair-")),
);
tempRoots.push(worktreeRoot);
const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json");
const sessionId = randomUUID();
await runSingleFakePhase({
adapter: new FakeSessionAdapter({ sessionIdFactory: () => sessionId, writeDelayMs: 0 }),
db,
expectedArtifactPath,
expectedSchema: "dev/spec@1",
instructions: "Scenario: timeout\nWrite the development specification.",
phaseId,
phaseKey: "implement",
roleId: "implementer",
runId,
worktreeRoot,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 10 },
workflowApprovalGateKey: "spec_approved",
uuidFactory: () => "00000000-0000-4000-8000-000000000036",
});
const events = await db
.select({ type: runEvents.type })
.from(runEvents)
.where(eq(runEvents.runId, runId))
.orderBy(runEvents.seq);
expect(events.map((event) => event.type)).toEqual([
"phase.started",
"session.created",
"session.ready",
"session.busy",
"prompt.sent",
"artifact.expected",
"artifact.timeout",
"session.recovered",
"phase.started",
"session.busy",
"prompt.repaired",
"artifact.expected",
"artifact.validated",
"approval.requested",
]);
});
it("does not mark a replayed valid artifact idle before requesting workflow approval", async () => {
const { db, phaseId, runId } = await createRunAndPhase("executing", "validating", 1);
await recordPhaseStarted(db, runId, phaseId);
const worktreeRoot = realpathSync(
mkdtempSync(join(tmpdir(), "devflow-fake-phase-workflow-gate-replay-")),
);
tempRoots.push(worktreeRoot);
const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json");
const instructions = "Scenario: ok\nWrite the development specification.";
const promptHash = hash({
attempt: 1,
expectedArtifact: expectedArtifactPath,
expectedSchema: "dev/spec@1",
instructions,
phaseKey: "implement",
roleId: "implementer",
runId,
});
const sessionId = randomUUID();
const artifactId = randomUUID();
const artifactHash = hash({ replay: "valid-workflow-gate" });
await db.insert(tuiSessions).values({
id: sessionId,
runId,
roleId: "implementer",
backend: "fake",
cwd: worktreeRoot,
expectedArtifactPath,
expectedSchema: "dev/spec@1",
lastPromptHash: promptHash,
lastPromptAt: new Date(Date.now() - 1000),
state: "BUSY",
});
await db.insert(artifacts).values({
id: artifactId,
runId,
phaseId,
path: expectedArtifactPath,
schemaId: "dev/spec@1",
hash: artifactHash,
valid: true,
});
await db.insert(runEvents).values({
runId,
phaseId,
seq: 2n,
type: "artifact.validated",
payload: {
artifactId,
hash: artifactHash,
path: expectedArtifactPath,
schemaId: "dev/spec@1",
},
idempotencyKey: `artifact.validated:${phaseId}:${expectedArtifactPath}:${artifactHash}`,
});
await runSingleFakePhase({
adapter: new FakeSessionAdapter({ sessionIdFactory: () => sessionId, writeDelayMs: 0 }),
db,
expectedArtifactPath,
expectedSchema: "dev/spec@1",
instructions,
phaseId,
phaseKey: "implement",
roleId: "implementer",
runId,
worktreeRoot,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
workflowApprovalGateKey: "spec_approved",
});
const [session] = await db
.select({ state: tuiSessions.state })
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionId));
expect(session).toEqual({ state: "WAITING_FOR_APPROVAL" });
const events = await db
.select({ type: runEvents.type })
.from(runEvents)
.where(eq(runEvents.runId, runId))
.orderBy(runEvents.seq);
expect(events.map((event) => event.type)).toEqual([
"phase.started",
"artifact.validated",
"approval.requested",
]);
});
it("resumes a running phase when prompt delivery succeeded before prompt.sent was recorded", async () => {
const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1);
await recordPhaseStarted(db, runId, phaseId);
@@ -662,6 +877,53 @@ describe("runSingleFakePhase", () => {
]);
});
it("requests a human gate when existing session resume exhausts retries", async () => {
const { db, phaseId, runId } = await createRunAndPhase();
const worktreeRoot = realpathSync(
mkdtempSync(join(tmpdir(), "devflow-fake-phase-resume-fails-")),
);
tempRoots.push(worktreeRoot);
const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json");
const sessionId = randomUUID();
await db.insert(tuiSessions).values({
id: sessionId,
runId,
roleId: "implementer",
backend: "fake",
cwd: worktreeRoot,
expectedArtifactPath,
expectedSchema: "dev/spec@1",
state: "READY",
});
const adapter = new ResumeFailsFakeAdapter();
await expect(
runSingleFakePhase({
adapter,
db,
expectedArtifactPath,
expectedSchema: "dev/spec@1",
instructions: "Scenario: ok\nExisting session resume fails.",
phaseId,
phaseKey: "implement",
roleId: "implementer",
runId,
worktreeRoot,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
uuidFactory: () => "00000000-0000-4000-8000-000000000039",
}),
).rejects.toMatchObject({ code: "prompt_send_exhausted" });
expect(adapter.resumeAttempts).toBe(3);
const [run] = await db.select({ state: runs.state }).from(runs).where(eq(runs.id, runId));
expect(run).toEqual({ state: "paused" });
const [approval] = await db
.select({ gateKey: approvalRequests.gateKey, state: approvalRequests.state })
.from(approvalRequests)
.where(eq(approvalRequests.runId, runId));
expect(approval).toEqual({ gateKey: "prompt_send_exhausted", state: "pending" });
});
it("resumes a running phase when the crash happened before session creation", async () => {
const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1);
await recordPhaseStarted(db, runId, phaseId);
@@ -898,6 +1160,65 @@ describe("runSingleFakePhase", () => {
expect(run?.state).toBe("executing");
});
it("reuses an idle role session when a later running phase has not sent its prompt yet", async () => {
const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1);
await recordPhaseStarted(db, runId, phaseId);
const worktreeRoot = realpathSync(
mkdtempSync(join(tmpdir(), "devflow-fake-phase-reuse-idle-session-")),
);
tempRoots.push(worktreeRoot);
const previousArtifactPath = join(worktreeRoot, "artifacts", "previous-spec.json");
const expectedArtifactPath = join(worktreeRoot, "artifacts", "next-spec.json");
const adapter = new FakeSessionAdapter({ writeDelayMs: 0 });
const sessionHandle = await adapter.start({
backend: "fake",
cwd: worktreeRoot,
expectedArtifactPath: previousArtifactPath,
expectedSchema: "dev/spec@1",
roleId: "implementer",
runId,
});
await db.insert(tuiSessions).values({
id: sessionHandle.sessionId,
runId,
roleId: "implementer",
backend: "fake",
cwd: worktreeRoot,
expectedArtifactPath: previousArtifactPath,
expectedSchema: "dev/spec@1",
state: "READY",
});
await runSingleFakePhase({
adapter,
db,
expectedArtifactPath,
expectedSchema: "dev/spec@1",
instructions: "Scenario: ok\nWrite the development specification.",
phaseId,
phaseKey: "implement",
roleId: "implementer",
runId,
worktreeRoot,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
});
const [session] = await db
.select({
expectedArtifactPath: tuiSessions.expectedArtifactPath,
expectedSchema: tuiSessions.expectedSchema,
state: tuiSessions.state,
})
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionHandle.sessionId));
expect(session).toEqual({
expectedArtifactPath,
expectedSchema: "dev/spec@1",
state: "READY",
});
await expectRunCompleted(db, runId);
});
it("replays an invalid validating artifact and uses the one repair attempt", async () => {
const { db, phaseId, runId } = await createRunAndPhase("executing", "validating", 1);
await recordPhaseStarted(db, runId, phaseId);
@@ -1674,7 +1995,7 @@ describe("runSingleFakePhase", () => {
worktreeRoot,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
}),
).rejects.toMatchObject({ code: "internal_state_corruption" });
).rejects.toMatchObject({ code: "run_state_changed" });
const events = await db
.select({ type: runEvents.type })

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,3 @@
export * from "./engine.js";
export * from "./fake-phase-harness.js";
export * from "./run-event-repository.js";

View File

@@ -12,9 +12,7 @@
"test": "cd ../.. && vitest run --project packages/session"
},
"dependencies": {
"@devflow/core": "workspace:*"
},
"devDependencies": {
"@devflow/core": "workspace:*",
"@devflow/db": "workspace:*"
}
}

View File

@@ -1,3 +1,4 @@
export * from "./adapter.js";
export * from "./fake.js";
export * from "./manager.js";
export * from "./transcript.js";

View File

@@ -0,0 +1,410 @@
import { DevflowError, type PromptEnvelope } from "@devflow/core";
import {
type DbClient,
RunEventRepository,
approvalRequests,
runs,
tuiSessions,
} from "@devflow/db";
import { and, eq, inArray, ne, notInArray, sql } from "drizzle-orm";
import type {
ProbeResult,
SessionAdapter,
SessionHandle,
StartInput,
TranscriptChunk,
} from "./adapter.js";
type Database = DbClient["db"];
interface AdvisoryLockClient {
query<T extends Record<string, unknown> = Record<string, unknown>>(
text: string,
values?: readonly unknown[],
): Promise<{ rows: T[] }>;
release(): void;
}
export interface SessionRuntime {
trackOperation<T>(operation: Promise<T>): Promise<T>;
start(input: StartInput): Promise<SessionHandle>;
sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }>;
probe(handle: SessionHandle): Promise<ProbeResult>;
resume(handle: SessionHandle): Promise<SessionHandle>;
rebootstrap(handle: SessionHandle): Promise<SessionHandle>;
capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk>;
dispose(handle: SessionHandle): Promise<void>;
}
export interface SessionManagerOptions {
adapter: SessionAdapter;
db?: Database;
dbClient?: DbClient;
recoveryRunIds?: readonly string[];
shutdownDrainMs?: number;
}
export interface SessionManagerRecoveryResult {
recoveredSessionIds: string[];
failedSessionIds: string[];
}
export class SessionManager implements SessionRuntime {
private readonly adapter: SessionAdapter;
private readonly db: Database | undefined;
private readonly dbClient: DbClient | undefined;
private readonly recoveryRunIds: readonly string[] | undefined;
private readonly shutdownDrainMs: number;
private readonly handles = new Map<string, SessionHandle>();
private readonly inFlight = new Set<Promise<unknown>>();
private lockClient: AdvisoryLockClient | undefined;
private draining = false;
constructor(options: SessionManagerOptions) {
this.adapter = options.adapter;
this.db = options.dbClient?.db ?? options.db;
this.dbClient = options.dbClient;
this.recoveryRunIds = options.recoveryRunIds;
this.shutdownDrainMs = options.shutdownDrainMs ?? 30_000;
}
async initialize(): Promise<SessionManagerRecoveryResult> {
await this.acquireLock();
try {
return await this.recoverSessions();
} catch (error) {
await this.shutdown();
throw error;
}
}
async acquireLock(): Promise<void> {
if (this.dbClient === undefined) {
throw new DevflowError("SessionManager requires a DbClient for singleton startup", {
class: "fatal",
code: "internal_state_corruption",
});
}
if (this.lockClient !== undefined) {
return;
}
const client = (await this.dbClient.pool.connect()) as AdvisoryLockClient;
const result = await client.query<{ acquired: boolean }>(
"SELECT pg_try_advisory_lock(hashtext($1)) AS acquired",
["devflow:session-manager"],
);
if (result.rows[0]?.acquired !== true) {
client.release();
throw new DevflowError("another session manager is running", {
class: "human_required",
code: "session_manager_already_running",
recoveryHint: "exit_code=3",
});
}
this.lockClient = client;
}
async shutdown(): Promise<void> {
this.draining = true;
await this.waitForInFlight();
const client = this.lockClient;
this.lockClient = undefined;
this.handles.clear();
if (client !== undefined) {
try {
await client.query("SELECT pg_advisory_unlock(hashtext($1))", ["devflow:session-manager"]);
} finally {
client.release();
}
}
}
trackOperation<T>(operation: Promise<T>): Promise<T> {
return this.track(operation);
}
async start(input: StartInput): Promise<SessionHandle> {
this.assertAcceptingPrompts();
const handle = await this.track(this.adapter.start(input));
this.handles.set(handle.sessionId, handle);
return handle;
}
async sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }> {
this.assertAcceptingPrompts();
return this.track(this.adapter.sendPrompt(this.handleFor(handle), envelope));
}
async probe(handle: SessionHandle): Promise<ProbeResult> {
return this.track(this.adapter.probe(this.handleFor(handle)));
}
async resume(handle: SessionHandle): Promise<SessionHandle> {
this.assertAcceptingPrompts();
const resumed = await this.track(this.adapter.resume(this.handleFor(handle)));
this.handles.set(resumed.sessionId, resumed);
return resumed;
}
async rebootstrap(handle: SessionHandle): Promise<SessionHandle> {
this.assertAcceptingPrompts();
const rebootstrapped = await this.track(this.adapter.rebootstrap(this.handleFor(handle)));
this.handles.set(rebootstrapped.sessionId, rebootstrapped);
return rebootstrapped;
}
async *capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk> {
const finishTracking = this.beginTrackedOperation();
try {
for await (const chunk of this.adapter.capture(this.handleFor(handle), fromSeq)) {
yield chunk;
}
} finally {
finishTracking();
}
}
async dispose(handle: SessionHandle): Promise<void> {
const resolvedHandle = this.handleFor(handle);
await this.track(this.adapter.dispose(resolvedHandle));
this.handles.delete(resolvedHandle.sessionId);
}
async recoverSessions(): Promise<SessionManagerRecoveryResult> {
if (this.db === undefined) {
return { recoveredSessionIds: [], failedSessionIds: [] };
}
const sessionRows = await this.db
.select({
id: tuiSessions.id,
runId: tuiSessions.runId,
roleId: tuiSessions.roleId,
backend: tuiSessions.backend,
cwd: tuiSessions.cwd,
lastKnownPanePid: tuiSessions.lastKnownPanePid,
recoveryAttempts: tuiSessions.recoveryAttempts,
state: tuiSessions.state,
tmuxSession: tuiSessions.tmuxSession,
tmuxWindow: tuiSessions.tmuxWindow,
})
.from(tuiSessions)
.innerJoin(runs, eq(tuiSessions.runId, runs.id))
.where(
this.recoveryRunIds === undefined
? and(
ne(tuiSessions.state, "FAILED_NEEDS_HUMAN"),
notInArray(runs.state, [...terminalRunStates]),
)
: and(
ne(tuiSessions.state, "FAILED_NEEDS_HUMAN"),
notInArray(runs.state, [...terminalRunStates]),
inArray(tuiSessions.runId, [...this.recoveryRunIds]),
),
);
const recoveredSessionIds: string[] = [];
const failedSessionIds: string[] = [];
for (const session of sessionRows) {
const handle = compactHandle(
session.id,
session.lastKnownPanePid,
session.tmuxSession,
session.tmuxWindow,
);
try {
const resumed = await this.resumeWithRetry(handle);
this.handles.set(resumed.sessionId, resumed);
recoveredSessionIds.push(resumed.sessionId);
} catch (error) {
await this.markRecoveryFailed(session, error);
failedSessionIds.push(session.id);
}
}
return { recoveredSessionIds, failedSessionIds };
}
private async markRecoveryFailed(
session: {
id: string;
runId: string;
roleId: string;
backend: string;
cwd: string;
recoveryAttempts: number;
},
error: unknown,
): Promise<void> {
if (this.db === undefined) {
return;
}
const eventRepository = new RunEventRepository(this.db);
const recoveryAttempts = session.recoveryAttempts + 1;
const gateKey = "session_recovery_required";
const approvalIdempotencyKey = `${session.runId}:${gateKey}:${session.id}:${recoveryAttempts}`;
const pauseCause = `session_recovery_failed:${session.id}:${recoveryAttempts}`;
await this.db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM ${runs} WHERE ${runs.id} = ${session.runId} FOR UPDATE`);
const [run] = await tx
.select({ state: runs.state })
.from(runs)
.where(eq(runs.id, session.runId))
.limit(1);
await tx
.update(tuiSessions)
.set({ state: "FAILED_NEEDS_HUMAN", recoveryAttempts })
.where(eq(tuiSessions.id, session.id));
await eventRepository.appendInTransaction(tx, {
runId: session.runId,
type: "session.failed",
payload: { sessionId: session.id, roleId: session.roleId },
idempotencyKey: `session.failed:${session.id}`,
});
if (run === undefined || isTerminalRunState(run.state)) {
return;
}
const inserted = await tx
.insert(approvalRequests)
.values({
runId: session.runId,
gateKey,
state: "pending",
idempotencyKey: approvalIdempotencyKey,
payload: {
sessionId: session.id,
roleId: session.roleId,
backend: session.backend,
cwd: session.cwd,
recoveryHint: recoveryHintFor(error),
},
})
.onConflictDoNothing({ target: approvalRequests.idempotencyKey })
.returning({ id: approvalRequests.id, idempotencyKey: approvalRequests.idempotencyKey });
if (run.state !== "paused") {
await tx
.update(runs)
.set({ state: "paused", pausedFromState: run.state, updatedAt: new Date() })
.where(eq(runs.id, session.runId));
await eventRepository.appendInTransaction(tx, {
runId: session.runId,
type: "run.paused",
payload: { cause: pauseCause, pausedFromState: run.state },
idempotencyKey: `run.paused:${session.runId}:${pauseCause}`,
});
}
const request =
inserted[0] ??
(
await tx
.select({ id: approvalRequests.id, idempotencyKey: approvalRequests.idempotencyKey })
.from(approvalRequests)
.where(eq(approvalRequests.idempotencyKey, approvalIdempotencyKey))
.limit(1)
)[0];
if (request !== undefined) {
await eventRepository.appendInTransaction(tx, {
runId: session.runId,
type: "approval.requested",
payload: {
approvalRequestId: request.id,
approvalIdempotencyKey: request.idempotencyKey,
gateKey,
},
idempotencyKey: `approval.requested:${request.idempotencyKey}`,
});
}
});
}
private async resumeWithRetry(handle: SessionHandle): Promise<SessionHandle> {
let lastError: unknown;
for (let attempt = 0; attempt <= 2; attempt += 1) {
try {
return await this.track(this.adapter.resume(handle));
} catch (error) {
lastError = error;
if (!(error instanceof DevflowError) || error.class !== "recoverable") {
throw error;
}
}
}
throw lastError;
}
private async track<T>(operation: Promise<T>): Promise<T> {
const tracked = operation.finally(() => {
this.inFlight.delete(tracked);
});
this.inFlight.add(tracked);
return tracked;
}
private beginTrackedOperation(): () => void {
let finishOperation!: () => void;
const tracked = new Promise<void>((resolve) => {
finishOperation = resolve;
}).finally(() => {
this.inFlight.delete(tracked);
});
this.inFlight.add(tracked);
return finishOperation;
}
private async waitForInFlight(): Promise<void> {
if (this.inFlight.size === 0) {
return;
}
await Promise.race([
Promise.allSettled([...this.inFlight]),
new Promise((resolveWait) => setTimeout(resolveWait, this.shutdownDrainMs)),
]);
}
private handleFor(handle: SessionHandle): SessionHandle {
return this.handles.get(handle.sessionId) ?? handle;
}
private assertAcceptingPrompts(): void {
if (this.draining) {
throw new DevflowError("SessionManager is draining", {
class: "human_required",
code: "session_manager_draining",
});
}
}
}
const terminalRunStates = ["completed", "failed", "aborted"] as const;
function isTerminalRunState(state: string): state is (typeof terminalRunStates)[number] {
return terminalRunStates.includes(state as (typeof terminalRunStates)[number]);
}
function compactHandle(
sessionId: string,
pid: number | null,
tmuxSession: string | null,
tmuxWindow: string | null,
): SessionHandle {
return {
sessionId,
...(pid === null ? {} : { pid }),
...(tmuxSession === null ? {} : { tmuxSession }),
...(tmuxWindow === null ? {} : { tmuxWindow }),
};
}
function recoveryHintFor(error: unknown): string {
if (error instanceof DevflowError && error.recoveryHint !== undefined) {
return error.recoveryHint;
}
if (error instanceof Error) {
return error.message;
}
return "session resume failed";
}

View File

@@ -6,5 +6,5 @@
"types": ["node", "vitest"]
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../core" }]
"references": [{ "path": "../core" }, { "path": "../db" }]
}