feat: implement session restore for crashed/exited agents (#104)
* feat: implement session restore for crashed/exited agents
Add true in-place session restore: same session ID, same worktree, same
metadata — optionally resuming the Claude Code conversation via --resume.
Core changes:
- Add TERMINAL_STATUSES, TERMINAL_ACTIVITIES, NON_RESTORABLE_STATUSES sets
and isTerminalSession/isRestorable helpers to types.ts
- Add SessionNotRestorableError and WorkspaceMissingError error classes
- Add restore() to SessionManager with 9-step flow: find metadata →
validate restorability → check/recreate workspace → get restore or
launch command → create runtime → update metadata
- Add restoredAt field to Session and SessionMetadata
Plugin extensions:
- workspace-worktree: exists() + restore() (git worktree prune + re-add)
- workspace-clone: exists() + restore() (git clone + checkout)
- scm-github: branchExists() via git rev-parse
- agent-claude-code: getRestoreCommand() finds latest JSONL session file
and builds claude --resume command
CLI + Web:
- Add `ao session restore <id>` subcommand
- Web restore API route uses sessionManager.restore() instead of spawn()
- SessionCard uses centralized TERMINAL_STATUSES/TERMINAL_ACTIVITIES
- Web types re-export core constants with sync tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add "merged" to TERMINAL_STATUSES
The old inline isTerminal check included "merged" but when refactored
to use the TERMINAL_STATUSES set, "merged" was omitted. This caused
merged sessions (whose activity is not "exited") to incorrectly show
the "terminal" link and "terminate session" button.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: enrich runtime state before restore check, remove dead branchExists
- Add enrichSessionWithRuntimeState() call before isRestorable() in
restore() so crashed sessions (status "working", agent exited) are
correctly detected as terminal and eligible for restore.
- Remove dead branchExists from SCM interface and scm-github plugin
(defined but never called anywhere in the codebase).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: allow restore of crashed working sessions
Remove "working" from NON_RESTORABLE_STATUSES. The isTerminalSession()
gate already prevents restoring truly active sessions (activity is not
"exited"). This fix allows crashed agents (status "working", activity
"exited") to be restored, aligning core behavior with the UI which
already shows the restore button for this case.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: distinguish missing branch from missing restore support
Split the compound condition so workspace restore gives an accurate
error message when branch metadata is null ("branch metadata is
missing") vs when the workspace plugin lacks a restore method.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
de6653e258
commit
65fa811b3b
|
|
@ -1,6 +1,6 @@
|
|||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { loadConfig } from "@composio/ao-core";
|
||||
import { loadConfig, SessionNotRestorableError, WorkspaceMissingError } from "@composio/ao-core";
|
||||
import { git, getTmuxActivity } from "../lib/shell.js";
|
||||
import { formatAge } from "../lib/format.js";
|
||||
import { getSessionManager } from "../lib/create-session-manager.js";
|
||||
|
|
@ -146,10 +146,39 @@ export function registerSession(program: Command): void {
|
|||
console.error(chalk.red(` Error cleaning ${sessionId}: ${error}`));
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
chalk.green(`\nCleanup complete. ${result.killed.length} sessions cleaned.`),
|
||||
);
|
||||
console.log(chalk.green(`\nCleanup complete. ${result.killed.length} sessions cleaned.`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
session
|
||||
.command("restore")
|
||||
.description("Restore a terminated/crashed session in-place")
|
||||
.argument("<session>", "Session name to restore")
|
||||
.action(async (sessionName: string) => {
|
||||
const config = loadConfig();
|
||||
const sm = await getSessionManager(config);
|
||||
|
||||
try {
|
||||
const restored = await sm.restore(sessionName);
|
||||
console.log(chalk.green(`\nSession ${sessionName} restored.`));
|
||||
if (restored.workspacePath) {
|
||||
console.log(chalk.dim(` Worktree: ${restored.workspacePath}`));
|
||||
}
|
||||
if (restored.branch) {
|
||||
console.log(chalk.dim(` Branch: ${restored.branch}`));
|
||||
}
|
||||
const tmuxTarget = restored.runtimeHandle?.id ?? sessionName;
|
||||
console.log(chalk.dim(` Attach: tmux attach -t ${tmuxTarget}`));
|
||||
} catch (err) {
|
||||
if (err instanceof SessionNotRestorableError) {
|
||||
console.error(chalk.red(`Cannot restore: ${err.reason}`));
|
||||
} else if (err instanceof WorkspaceMissingError) {
|
||||
console.error(chalk.red(`Workspace missing: ${err.message}`));
|
||||
} else {
|
||||
console.error(chalk.red(`Failed to restore session ${sessionName}: ${err}`));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ beforeEach(() => {
|
|||
mockSessionManager = {
|
||||
spawn: vi.fn(),
|
||||
spawnOrchestrator: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
kill: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
|
|||
|
|
@ -4,17 +4,19 @@ import { join } from "node:path";
|
|||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createSessionManager } from "../session-manager.js";
|
||||
import { writeMetadata, readMetadata } from "../metadata.js";
|
||||
import { writeMetadata, readMetadata, readMetadataRaw } from "../metadata.js";
|
||||
import { getSessionsDir, getProjectBaseDir } from "../paths.js";
|
||||
import type {
|
||||
OrchestratorConfig,
|
||||
PluginRegistry,
|
||||
Runtime,
|
||||
Agent,
|
||||
Workspace,
|
||||
Tracker,
|
||||
SCM,
|
||||
RuntimeHandle,
|
||||
import {
|
||||
SessionNotRestorableError,
|
||||
WorkspaceMissingError,
|
||||
type OrchestratorConfig,
|
||||
type PluginRegistry,
|
||||
type Runtime,
|
||||
type Agent,
|
||||
type Workspace,
|
||||
type Tracker,
|
||||
type SCM,
|
||||
type RuntimeHandle,
|
||||
} from "../types.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
|
@ -875,6 +877,248 @@ describe("spawnOrchestrator", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("restore", () => {
|
||||
it("restores a killed session with existing workspace", async () => {
|
||||
// Create a workspace directory that exists
|
||||
const wsPath = join(tmpDir, "ws-app-1");
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: wsPath,
|
||||
branch: "feat/TEST-1",
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
issue: "TEST-1",
|
||||
pr: "https://github.com/org/my-app/pull/10",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-old")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
const restored = await sm.restore("app-1");
|
||||
|
||||
expect(restored.id).toBe("app-1");
|
||||
expect(restored.status).toBe("spawning");
|
||||
expect(restored.activity).toBe("active");
|
||||
expect(restored.workspacePath).toBe(wsPath);
|
||||
expect(restored.branch).toBe("feat/TEST-1");
|
||||
expect(restored.runtimeHandle).toEqual(makeHandle("rt-1"));
|
||||
expect(restored.restoredAt).toBeInstanceOf(Date);
|
||||
|
||||
// Verify runtime was created
|
||||
expect(mockRuntime.create).toHaveBeenCalled();
|
||||
// Verify metadata was updated (not rewritten)
|
||||
const meta = readMetadataRaw(sessionsDir, "app-1");
|
||||
expect(meta!["status"]).toBe("spawning");
|
||||
expect(meta!["restoredAt"]).toBeDefined();
|
||||
// Verify original fields are preserved
|
||||
expect(meta!["issue"]).toBe("TEST-1");
|
||||
expect(meta!["pr"]).toBe("https://github.com/org/my-app/pull/10");
|
||||
expect(meta!["createdAt"]).toBe("2025-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("recreates workspace when missing and plugin supports restore", async () => {
|
||||
const wsPath = join(tmpDir, "ws-app-1");
|
||||
// DO NOT create the directory — it's missing
|
||||
|
||||
const mockWorkspaceWithRestore: Workspace = {
|
||||
...mockWorkspace,
|
||||
exists: vi.fn().mockResolvedValue(false),
|
||||
restore: vi.fn().mockResolvedValue({
|
||||
path: wsPath,
|
||||
branch: "feat/TEST-1",
|
||||
sessionId: "app-1",
|
||||
projectId: "my-app",
|
||||
}),
|
||||
};
|
||||
|
||||
const registryWithRestore: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return mockAgent;
|
||||
if (slot === "workspace") return mockWorkspaceWithRestore;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: wsPath,
|
||||
branch: "feat/TEST-1",
|
||||
status: "terminated",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-old")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithRestore });
|
||||
const restored = await sm.restore("app-1");
|
||||
|
||||
expect(restored.id).toBe("app-1");
|
||||
expect(mockWorkspaceWithRestore.restore).toHaveBeenCalled();
|
||||
expect(mockRuntime.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws SessionNotRestorableError for merged sessions", async () => {
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "merged",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.restore("app-1")).rejects.toThrow(SessionNotRestorableError);
|
||||
});
|
||||
|
||||
it("throws SessionNotRestorableError for working sessions", async () => {
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.restore("app-1")).rejects.toThrow(SessionNotRestorableError);
|
||||
});
|
||||
|
||||
it("throws WorkspaceMissingError when workspace gone and no restore method", async () => {
|
||||
const wsPath = join(tmpDir, "nonexistent-ws");
|
||||
|
||||
const mockWorkspaceNoRestore: Workspace = {
|
||||
...mockWorkspace,
|
||||
exists: vi.fn().mockResolvedValue(false),
|
||||
// No restore method
|
||||
};
|
||||
|
||||
const registryNoRestore: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return mockAgent;
|
||||
if (slot === "workspace") return mockWorkspaceNoRestore;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: wsPath,
|
||||
branch: "feat/TEST-1",
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-old")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryNoRestore });
|
||||
await expect(sm.restore("app-1")).rejects.toThrow(WorkspaceMissingError);
|
||||
});
|
||||
|
||||
it("throws for nonexistent session", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.restore("nonexistent")).rejects.toThrow("not found");
|
||||
});
|
||||
|
||||
it("uses getRestoreCommand when available", async () => {
|
||||
const wsPath = join(tmpDir, "ws-app-1");
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
|
||||
const mockAgentWithRestore: Agent = {
|
||||
...mockAgent,
|
||||
getRestoreCommand: vi.fn().mockResolvedValue("claude --resume abc123"),
|
||||
};
|
||||
|
||||
const registryWithAgentRestore: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return mockAgentWithRestore;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: wsPath,
|
||||
branch: "feat/TEST-1",
|
||||
status: "errored",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-old")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithAgentRestore });
|
||||
await sm.restore("app-1");
|
||||
|
||||
expect(mockAgentWithRestore.getRestoreCommand).toHaveBeenCalled();
|
||||
// Verify runtime.create was called with the restore command
|
||||
const createCall = (mockRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(createCall.launchCommand).toBe("claude --resume abc123");
|
||||
});
|
||||
|
||||
it("falls back to getLaunchCommand when getRestoreCommand returns null", async () => {
|
||||
const wsPath = join(tmpDir, "ws-app-1");
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
|
||||
const mockAgentWithNullRestore: Agent = {
|
||||
...mockAgent,
|
||||
getRestoreCommand: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const registryWithNullRestore: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return mockAgentWithNullRestore;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: wsPath,
|
||||
branch: "feat/TEST-1",
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-old")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithNullRestore });
|
||||
await sm.restore("app-1");
|
||||
|
||||
expect(mockAgentWithNullRestore.getRestoreCommand).toHaveBeenCalled();
|
||||
expect(mockAgent.getLaunchCommand).toHaveBeenCalled();
|
||||
const createCall = (mockRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(createCall.launchCommand).toBe("mock-agent --start");
|
||||
});
|
||||
|
||||
it("preserves original createdAt/issue/PR metadata", async () => {
|
||||
const wsPath = join(tmpDir, "ws-app-1");
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
|
||||
const originalCreatedAt = "2024-06-15T10:00:00.000Z";
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: wsPath,
|
||||
branch: "feat/TEST-42",
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
issue: "TEST-42",
|
||||
pr: "https://github.com/org/my-app/pull/99",
|
||||
summary: "Implementing feature X",
|
||||
createdAt: originalCreatedAt,
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-old")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.restore("app-1");
|
||||
|
||||
const meta = readMetadataRaw(sessionsDir, "app-1");
|
||||
expect(meta!["createdAt"]).toBe(originalCreatedAt);
|
||||
expect(meta!["issue"]).toBe("TEST-42");
|
||||
expect(meta!["pr"]).toBe("https://github.com/org/my-app/pull/99");
|
||||
expect(meta!["summary"]).toBe("Implementing feature X");
|
||||
expect(meta!["branch"]).toBe("feat/TEST-42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginRegistry.loadBuiltins importFn", () => {
|
||||
it("should use provided importFn instead of built-in import", async () => {
|
||||
const { createPluginRegistry: createReg } = await import("../plugin-registry.js");
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import { statSync, existsSync, readdirSync, writeFileSync, mkdirSync } from "nod
|
|||
import { join } from "node:path";
|
||||
import {
|
||||
isIssueNotFoundError,
|
||||
isRestorable,
|
||||
NON_RESTORABLE_STATUSES,
|
||||
SessionNotRestorableError,
|
||||
WorkspaceMissingError,
|
||||
type SessionManager,
|
||||
type Session,
|
||||
type SessionId,
|
||||
|
|
@ -37,12 +41,19 @@ import {
|
|||
import {
|
||||
readMetadataRaw,
|
||||
writeMetadata,
|
||||
updateMetadata,
|
||||
deleteMetadata,
|
||||
listMetadata,
|
||||
reserveSessionId,
|
||||
} from "./metadata.js";
|
||||
import { buildPrompt } from "./prompt-builder.js";
|
||||
import { getSessionsDir, getProjectBaseDir, generateTmuxName, generateConfigHash, validateAndStoreOrigin } from "./paths.js";
|
||||
import {
|
||||
getSessionsDir,
|
||||
getProjectBaseDir,
|
||||
generateTmuxName,
|
||||
generateConfigHash,
|
||||
validateAndStoreOrigin,
|
||||
} from "./paths.js";
|
||||
|
||||
/** Escape regex metacharacters in a string. */
|
||||
function escapeRegex(str: string): string {
|
||||
|
|
@ -266,10 +277,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
// external scripts (which don't store runtimeHandle) to always show "unknown".
|
||||
if (plugins.agent) {
|
||||
try {
|
||||
const detected = await plugins.agent.getActivityState(
|
||||
session,
|
||||
config.readyThresholdMs,
|
||||
);
|
||||
const detected = await plugins.agent.getActivityState(session, config.readyThresholdMs);
|
||||
if (detected !== null) {
|
||||
session.activity = detected;
|
||||
}
|
||||
|
|
@ -762,7 +770,10 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
deleteMetadata(sessionsDir, sessionId, true);
|
||||
}
|
||||
|
||||
async function cleanup(projectId?: string, options?: { dryRun?: boolean }): Promise<CleanupResult> {
|
||||
async function cleanup(
|
||||
projectId?: string,
|
||||
options?: { dryRun?: boolean },
|
||||
): Promise<CleanupResult> {
|
||||
const result: CleanupResult = { killed: [], skipped: [], errors: [] };
|
||||
const sessions = await list(projectId);
|
||||
|
||||
|
|
@ -869,5 +880,152 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
await runtimePlugin.sendMessage(handle, message);
|
||||
}
|
||||
|
||||
return { spawn, spawnOrchestrator, list, get, kill, cleanup, send };
|
||||
async function restore(sessionId: SessionId): Promise<Session> {
|
||||
// 1. Find session metadata across all projects
|
||||
let raw: Record<string, string> | null = null;
|
||||
let sessionsDir: string | null = null;
|
||||
let project: ProjectConfig | undefined;
|
||||
let projectId: string | undefined;
|
||||
|
||||
for (const [key, proj] of Object.entries(config.projects)) {
|
||||
const dir = getProjectSessionsDir(proj);
|
||||
const metadata = readMetadataRaw(dir, sessionId);
|
||||
if (metadata) {
|
||||
raw = metadata;
|
||||
sessionsDir = dir;
|
||||
project = proj;
|
||||
projectId = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw || !sessionsDir || !project || !projectId) {
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
// 2. Reconstruct Session from metadata and enrich with live runtime state.
|
||||
// metadataToSession sets activity: null, so without enrichment a crashed
|
||||
// session (status "working", agent exited) would not be detected as terminal
|
||||
// and isRestorable would reject it.
|
||||
const session = metadataToSession(sessionId, raw);
|
||||
const plugins = resolvePlugins(project);
|
||||
await enrichSessionWithRuntimeState(session, plugins, true);
|
||||
|
||||
// 3. Validate restorability
|
||||
if (!isRestorable(session)) {
|
||||
if (NON_RESTORABLE_STATUSES.has(session.status)) {
|
||||
throw new SessionNotRestorableError(sessionId, `status is "${session.status}"`);
|
||||
}
|
||||
throw new SessionNotRestorableError(sessionId, "session is not in a terminal state");
|
||||
}
|
||||
|
||||
// 4. Validate required plugins (plugins already resolved above for enrichment)
|
||||
if (!plugins.runtime) {
|
||||
throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`);
|
||||
}
|
||||
if (!plugins.agent) {
|
||||
throw new Error(`Agent plugin '${project.agent ?? config.defaults.agent}' not found`);
|
||||
}
|
||||
|
||||
// 5. Check workspace
|
||||
const workspacePath = raw["worktree"] || project.path;
|
||||
const workspaceExists = plugins.workspace?.exists
|
||||
? await plugins.workspace.exists(workspacePath)
|
||||
: existsSync(workspacePath);
|
||||
|
||||
if (!workspaceExists) {
|
||||
// Try to restore workspace if plugin supports it
|
||||
if (!plugins.workspace?.restore) {
|
||||
throw new WorkspaceMissingError(workspacePath, "workspace plugin does not support restore");
|
||||
}
|
||||
if (!session.branch) {
|
||||
throw new WorkspaceMissingError(workspacePath, "branch metadata is missing");
|
||||
}
|
||||
try {
|
||||
const wsInfo = await plugins.workspace.restore(
|
||||
{
|
||||
projectId,
|
||||
project,
|
||||
sessionId,
|
||||
branch: session.branch,
|
||||
},
|
||||
workspacePath,
|
||||
);
|
||||
|
||||
// Run post-create hooks on restored workspace
|
||||
if (plugins.workspace.postCreate) {
|
||||
await plugins.workspace.postCreate(wsInfo, project);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new WorkspaceMissingError(
|
||||
workspacePath,
|
||||
`restore failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Get launch command — try restore command first, fall back to fresh launch
|
||||
let launchCommand: string;
|
||||
const agentLaunchConfig = {
|
||||
sessionId,
|
||||
projectConfig: project,
|
||||
issueId: session.issueId ?? undefined,
|
||||
permissions: project.agentConfig?.permissions,
|
||||
model: project.agentConfig?.model,
|
||||
};
|
||||
|
||||
if (plugins.agent.getRestoreCommand) {
|
||||
const restoreCmd = await plugins.agent.getRestoreCommand(session, project);
|
||||
launchCommand = restoreCmd ?? plugins.agent.getLaunchCommand(agentLaunchConfig);
|
||||
} else {
|
||||
launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig);
|
||||
}
|
||||
|
||||
const environment = plugins.agent.getEnvironment(agentLaunchConfig);
|
||||
|
||||
// 7. Create runtime (reuse tmuxName from metadata)
|
||||
const tmuxName = raw["tmuxName"];
|
||||
const handle = await plugins.runtime.create({
|
||||
sessionId: tmuxName ?? sessionId,
|
||||
workspacePath,
|
||||
launchCommand,
|
||||
environment: {
|
||||
...environment,
|
||||
AO_SESSION: sessionId,
|
||||
AO_DATA_DIR: sessionsDir,
|
||||
AO_SESSION_NAME: sessionId,
|
||||
...(tmuxName && { AO_TMUX_NAME: tmuxName }),
|
||||
},
|
||||
});
|
||||
|
||||
// 8. Update metadata — merge updates, preserving existing fields
|
||||
const now = new Date().toISOString();
|
||||
updateMetadata(sessionsDir, sessionId, {
|
||||
status: "spawning",
|
||||
runtimeHandle: JSON.stringify(handle),
|
||||
restoredAt: now,
|
||||
});
|
||||
|
||||
// 9. Run postLaunchSetup (non-fatal)
|
||||
const restoredSession: Session = {
|
||||
...session,
|
||||
status: "spawning",
|
||||
activity: "active",
|
||||
workspacePath,
|
||||
runtimeHandle: handle,
|
||||
restoredAt: new Date(now),
|
||||
};
|
||||
|
||||
if (plugins.agent.postLaunchSetup) {
|
||||
try {
|
||||
await plugins.agent.postLaunchSetup(restoredSession);
|
||||
} catch {
|
||||
// Non-fatal — session is already running
|
||||
}
|
||||
}
|
||||
|
||||
return restoredSession;
|
||||
}
|
||||
|
||||
return { spawn, spawnOrchestrator, restore, list, get, kill, cleanup, send };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,41 @@ export const SESSION_STATUS = {
|
|||
TERMINATED: "terminated" as const,
|
||||
} satisfies Record<string, SessionStatus>;
|
||||
|
||||
/** Statuses that indicate the session is in a terminal (dead) state. */
|
||||
export const TERMINAL_STATUSES: ReadonlySet<SessionStatus> = new Set([
|
||||
"killed",
|
||||
"terminated",
|
||||
"done",
|
||||
"cleanup",
|
||||
"errored",
|
||||
"merged",
|
||||
]);
|
||||
|
||||
/** Activity states that indicate the session is no longer running. */
|
||||
export const TERMINAL_ACTIVITIES: ReadonlySet<ActivityState> = new Set(["exited"]);
|
||||
|
||||
/** Statuses that must never be restored (e.g. already merged). */
|
||||
export const NON_RESTORABLE_STATUSES: ReadonlySet<SessionStatus> = new Set(["merged"]);
|
||||
|
||||
/** Check if a session is in a terminal (dead) state. */
|
||||
export function isTerminalSession(session: {
|
||||
status: SessionStatus;
|
||||
activity: ActivityState | null;
|
||||
}): boolean {
|
||||
return (
|
||||
TERMINAL_STATUSES.has(session.status) ||
|
||||
(session.activity !== null && TERMINAL_ACTIVITIES.has(session.activity))
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if a session can be restored. */
|
||||
export function isRestorable(session: {
|
||||
status: SessionStatus;
|
||||
activity: ActivityState | null;
|
||||
}): boolean {
|
||||
return isTerminalSession(session) && !NON_RESTORABLE_STATUSES.has(session.status);
|
||||
}
|
||||
|
||||
/** A running agent session */
|
||||
export interface Session {
|
||||
/** Unique session ID, e.g. "my-app-3" */
|
||||
|
|
@ -121,6 +156,9 @@ export interface Session {
|
|||
/** Last activity timestamp */
|
||||
lastActivityAt: Date;
|
||||
|
||||
/** When this session was last restored (undefined if never restored) */
|
||||
restoredAt?: Date;
|
||||
|
||||
/** Metadata key-value pairs */
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
|
@ -243,6 +281,12 @@ export interface Agent {
|
|||
/** Extract information from agent's internal data (summary, cost, session ID) */
|
||||
getSessionInfo(session: Session): Promise<AgentSessionInfo | null>;
|
||||
|
||||
/**
|
||||
* Optional: get a launch command that resumes a previous session.
|
||||
* Returns null if no previous session is found (caller falls back to getLaunchCommand).
|
||||
*/
|
||||
getRestoreCommand?(session: Session, project: ProjectConfig): Promise<string | null>;
|
||||
|
||||
/** Optional: run setup after agent is launched (e.g. configure MCP servers) */
|
||||
postLaunchSetup?(session: Session): Promise<void>;
|
||||
|
||||
|
|
@ -339,6 +383,12 @@ export interface Workspace {
|
|||
|
||||
/** Optional: run hooks after workspace creation (symlinks, installs, etc.) */
|
||||
postCreate?(info: WorkspaceInfo, project: ProjectConfig): Promise<void>;
|
||||
|
||||
/** Optional: check if a workspace exists and is a valid git repo */
|
||||
exists?(workspacePath: string): Promise<boolean>;
|
||||
|
||||
/** Optional: restore a workspace (e.g. recreate a worktree for an existing branch) */
|
||||
restore?(config: WorkspaceCreateConfig, workspacePath: string): Promise<WorkspaceInfo>;
|
||||
}
|
||||
|
||||
export interface WorkspaceCreateConfig {
|
||||
|
|
@ -904,6 +954,7 @@ export interface SessionMetadata {
|
|||
project?: string;
|
||||
createdAt?: string;
|
||||
runtimeHandle?: string;
|
||||
restoredAt?: string;
|
||||
dashboardPort?: number;
|
||||
terminalWsPort?: number;
|
||||
directTerminalWsPort?: number;
|
||||
|
|
@ -917,6 +968,7 @@ export interface SessionMetadata {
|
|||
export interface SessionManager {
|
||||
spawn(config: SessionSpawnConfig): Promise<Session>;
|
||||
spawnOrchestrator(config: OrchestratorSpawnConfig): Promise<Session>;
|
||||
restore(sessionId: SessionId): Promise<Session>;
|
||||
list(projectId?: string): Promise<Session[]>;
|
||||
get(sessionId: SessionId): Promise<Session | null>;
|
||||
kill(sessionId: SessionId): Promise<void>;
|
||||
|
|
@ -996,3 +1048,25 @@ export function isIssueNotFoundError(err: unknown): boolean {
|
|||
message.includes("no issue with identifier")
|
||||
);
|
||||
}
|
||||
|
||||
/** Thrown when a session cannot be restored (e.g. merged, still working). */
|
||||
export class SessionNotRestorableError extends Error {
|
||||
constructor(
|
||||
public readonly sessionId: string,
|
||||
public readonly reason: string,
|
||||
) {
|
||||
super(`Session ${sessionId} cannot be restored: ${reason}`);
|
||||
this.name = "SessionNotRestorableError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a workspace is missing and cannot be recreated. */
|
||||
export class WorkspaceMissingError extends Error {
|
||||
constructor(
|
||||
public readonly path: string,
|
||||
public readonly detail?: string,
|
||||
) {
|
||||
super(`Workspace missing at ${path}${detail ? `: ${detail}` : ""}`);
|
||||
this.name = "WorkspaceMissingError";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type ActivityState,
|
||||
type CostEstimate,
|
||||
type PluginModule,
|
||||
type ProjectConfig,
|
||||
type RuntimeHandle,
|
||||
type Session,
|
||||
type WorkspaceHooksConfig,
|
||||
|
|
@ -722,6 +723,35 @@ function createClaudeCodeAgent(): Agent {
|
|||
};
|
||||
},
|
||||
|
||||
async getRestoreCommand(session: Session, project: ProjectConfig): Promise<string | null> {
|
||||
if (!session.workspacePath) return null;
|
||||
|
||||
// Find Claude's project directory for this workspace
|
||||
const projectPath = toClaudeProjectPath(session.workspacePath);
|
||||
const projectDir = join(homedir(), ".claude", "projects", projectPath);
|
||||
|
||||
// Find the latest session JSONL file
|
||||
const sessionFile = await findLatestSessionFile(projectDir);
|
||||
if (!sessionFile) return null;
|
||||
|
||||
// Extract session UUID from filename (e.g. "abc123-def456.jsonl" → "abc123-def456")
|
||||
const sessionUuid = basename(sessionFile, ".jsonl");
|
||||
if (!sessionUuid) return null;
|
||||
|
||||
// Build resume command
|
||||
const parts: string[] = ["claude", "--resume", shellEscape(sessionUuid)];
|
||||
|
||||
if (project.agentConfig?.permissions === "skip") {
|
||||
parts.push("--dangerously-skip-permissions");
|
||||
}
|
||||
|
||||
if (project.agentConfig?.model) {
|
||||
parts.push("--model", shellEscape(project.agentConfig.model as string));
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
},
|
||||
|
||||
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
// Use absolute path for hook command (specific to this workspace)
|
||||
const hookScriptPath = join(workspacePath, ".claude", "metadata-updater.sh");
|
||||
|
|
|
|||
|
|
@ -166,6 +166,64 @@ export function create(config?: Record<string, unknown>): Workspace {
|
|||
return infos;
|
||||
},
|
||||
|
||||
async exists(workspacePath: string): Promise<boolean> {
|
||||
if (!existsSync(workspacePath)) return false;
|
||||
try {
|
||||
await execFileAsync("git", ["rev-parse", "--is-inside-work-tree"], {
|
||||
cwd: workspacePath,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async restore(cfg: WorkspaceCreateConfig, workspacePath: string): Promise<WorkspaceInfo> {
|
||||
const repoPath = expandPath(cfg.project.path);
|
||||
|
||||
// Get remote URL
|
||||
let remoteUrl: string;
|
||||
try {
|
||||
remoteUrl = await git(repoPath, "remote", "get-url", "origin");
|
||||
} catch {
|
||||
remoteUrl = repoPath;
|
||||
}
|
||||
|
||||
// Clone fresh
|
||||
await execFileAsync("git", [
|
||||
"clone",
|
||||
"--reference",
|
||||
repoPath,
|
||||
"--branch",
|
||||
cfg.project.defaultBranch,
|
||||
remoteUrl,
|
||||
workspacePath,
|
||||
]);
|
||||
|
||||
// Try to checkout the branch
|
||||
try {
|
||||
await git(workspacePath, "checkout", cfg.branch);
|
||||
} catch {
|
||||
try {
|
||||
await git(workspacePath, "checkout", "-b", cfg.branch);
|
||||
} catch (checkoutErr: unknown) {
|
||||
rmSync(workspacePath, { recursive: true, force: true });
|
||||
const msg = checkoutErr instanceof Error ? checkoutErr.message : String(checkoutErr);
|
||||
throw new Error(`Failed to checkout branch "${cfg.branch}" during restore: ${msg}`, {
|
||||
cause: checkoutErr,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: workspacePath,
|
||||
branch: cfg.branch,
|
||||
sessionId: cfg.sessionId,
|
||||
projectId: cfg.projectId,
|
||||
};
|
||||
},
|
||||
|
||||
async postCreate(info: WorkspaceInfo, project: ProjectConfig): Promise<void> {
|
||||
// Run postCreate hooks
|
||||
// NOTE: commands run with full shell privileges — they come from trusted YAML config
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import type {
|
|||
ProjectConfig,
|
||||
} from "@composio/ao-core";
|
||||
|
||||
/** Timeout for git commands (30 seconds) */
|
||||
const GIT_TIMEOUT = 30_000;
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const manifest = {
|
||||
|
|
@ -190,6 +193,59 @@ export function create(config?: Record<string, unknown>): Workspace {
|
|||
return infos;
|
||||
},
|
||||
|
||||
async exists(workspacePath: string): Promise<boolean> {
|
||||
if (!existsSync(workspacePath)) return false;
|
||||
try {
|
||||
await execFileAsync("git", ["rev-parse", "--is-inside-work-tree"], {
|
||||
cwd: workspacePath,
|
||||
timeout: GIT_TIMEOUT,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async restore(cfg: WorkspaceCreateConfig, workspacePath: string): Promise<WorkspaceInfo> {
|
||||
const repoPath = expandPath(cfg.project.path);
|
||||
|
||||
// Prune stale worktree entries
|
||||
try {
|
||||
await git(repoPath, "worktree", "prune");
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
|
||||
// Fetch latest
|
||||
try {
|
||||
await git(repoPath, "fetch", "origin", "--quiet");
|
||||
} catch {
|
||||
// May fail if offline
|
||||
}
|
||||
|
||||
// Try to create worktree on the existing branch
|
||||
try {
|
||||
await git(repoPath, "worktree", "add", workspacePath, cfg.branch);
|
||||
} catch {
|
||||
// Branch might not exist locally — try from origin
|
||||
const remoteBranch = `origin/${cfg.branch}`;
|
||||
try {
|
||||
await git(repoPath, "worktree", "add", "-b", cfg.branch, workspacePath, remoteBranch);
|
||||
} catch {
|
||||
// Last resort: create from default branch
|
||||
const baseRef = `origin/${cfg.project.defaultBranch}`;
|
||||
await git(repoPath, "worktree", "add", "-b", cfg.branch, workspacePath, baseRef);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: workspacePath,
|
||||
branch: cfg.branch,
|
||||
sessionId: cfg.sessionId,
|
||||
projectId: cfg.projectId,
|
||||
};
|
||||
},
|
||||
|
||||
async postCreate(info: WorkspaceInfo, project: ProjectConfig): Promise<void> {
|
||||
const repoPath = expandPath(project.path);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import type {
|
||||
Session,
|
||||
SessionManager,
|
||||
OrchestratorConfig,
|
||||
PluginRegistry,
|
||||
SCM,
|
||||
import {
|
||||
SessionNotRestorableError,
|
||||
type Session,
|
||||
type SessionManager,
|
||||
type OrchestratorConfig,
|
||||
type PluginRegistry,
|
||||
type SCM,
|
||||
} from "@composio/ao-core";
|
||||
|
||||
// ── Mock Data ─────────────────────────────────────────────────────────
|
||||
|
|
@ -82,6 +83,17 @@ const mockSessionManager: SessionManager = {
|
|||
}),
|
||||
cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })),
|
||||
spawnOrchestrator: vi.fn(),
|
||||
restore: vi.fn(async (id: string) => {
|
||||
const session = testSessions.find((s) => s.id === id);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${id} not found`);
|
||||
}
|
||||
// Simulate SessionNotRestorableError for non-terminal sessions
|
||||
if (session.status === "working" && session.activity !== "exited") {
|
||||
throw new SessionNotRestorableError(id, "session is not in a terminal state");
|
||||
}
|
||||
return { ...session, status: "spawning" as const, activity: "active" as const };
|
||||
}),
|
||||
};
|
||||
|
||||
const mockSCM: SCM = {
|
||||
|
|
@ -114,9 +126,9 @@ const mockRegistry: PluginRegistry = {
|
|||
};
|
||||
|
||||
const mockConfig: OrchestratorConfig = {
|
||||
dataDir: "/tmp/ao-test",
|
||||
worktreeDir: "/tmp/ao-worktrees",
|
||||
configPath: "/tmp/ao-test/agent-orchestrator.yaml",
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
projects: {
|
||||
"my-app": {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,7 @@ import { type NextRequest, NextResponse } from "next/server";
|
|||
import { validateIdentifier } from "@/lib/validation";
|
||||
import { getServices } from "@/lib/services";
|
||||
import { sessionToDashboard } from "@/lib/serialize";
|
||||
|
||||
/** Terminal states that can be restored */
|
||||
const RESTORABLE_STATUSES = new Set(["killed", "cleanup", "terminated", "done"]);
|
||||
const RESTORABLE_ACTIVITIES = new Set(["exited"]);
|
||||
|
||||
/** Statuses that must never be restored (e.g. already merged) */
|
||||
const NON_RESTORABLE_STATUSES = new Set(["merged"]);
|
||||
import { SessionNotRestorableError, WorkspaceMissingError } from "@composio/ao-core";
|
||||
|
||||
/** POST /api/sessions/:id/restore — Restore a terminated session */
|
||||
export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
|
|
@ -20,36 +14,20 @@ export async function POST(_request: NextRequest, { params }: { params: Promise<
|
|||
|
||||
try {
|
||||
const { sessionManager } = await getServices();
|
||||
const session = await sessionManager.get(id);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (NON_RESTORABLE_STATUSES.has(session.status)) {
|
||||
return NextResponse.json({ error: "Cannot restore a merged session" }, { status: 409 });
|
||||
}
|
||||
|
||||
const isTerminal =
|
||||
RESTORABLE_STATUSES.has(session.status) ||
|
||||
(session.activity !== null && RESTORABLE_ACTIVITIES.has(session.activity));
|
||||
|
||||
if (!isTerminal) {
|
||||
return NextResponse.json({ error: "Session is not in a terminal state" }, { status: 409 });
|
||||
}
|
||||
|
||||
// Re-spawn with the same project and issue to create a fresh session
|
||||
const newSession = await sessionManager.spawn({
|
||||
projectId: session.projectId,
|
||||
issueId: session.issueId ?? undefined,
|
||||
branch: session.branch ?? undefined,
|
||||
});
|
||||
const restored = await sessionManager.restore(id);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
sessionId: id,
|
||||
newSession: sessionToDashboard(newSession),
|
||||
session: sessionToDashboard(restored),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof SessionNotRestorableError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 409 });
|
||||
}
|
||||
if (err instanceof WorkspaceMissingError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 422 });
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : "Failed to restore session";
|
||||
const status = msg.includes("not found") ? 404 : 500;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { type DashboardSession, type AttentionLevel, getAttentionLevel } from "@/lib/types";
|
||||
import {
|
||||
type DashboardSession,
|
||||
type AttentionLevel,
|
||||
getAttentionLevel,
|
||||
TERMINAL_STATUSES,
|
||||
TERMINAL_ACTIVITIES,
|
||||
} from "@/lib/types";
|
||||
import { CI_STATUS } from "@composio/ao-core/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { PRStatus } from "./PRStatus";
|
||||
|
|
@ -56,12 +62,11 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
|
|||
const alerts = getAlerts(session);
|
||||
const isReadyToMerge = pr?.mergeability.mergeable && pr.state === "open";
|
||||
const isTerminal =
|
||||
session.status === "killed" ||
|
||||
session.status === "cleanup" ||
|
||||
session.status === "terminated" ||
|
||||
session.status === "done" ||
|
||||
session.status === "merged" ||
|
||||
session.activity === "exited";
|
||||
TERMINAL_STATUSES.has(session.status) ||
|
||||
(session.activity !== null && TERMINAL_ACTIVITIES.has(session.activity));
|
||||
// UI restore button: more permissive than core isRestorable() — shows restore
|
||||
// when agent has exited even if status is "working" (agent crashed mid-work).
|
||||
// Only block "merged" (server-side restore will reject anyway).
|
||||
const isRestorable = isTerminal && session.status !== "merged";
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,7 +3,18 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getAttentionLevel, type DashboardSession } from "../types";
|
||||
import {
|
||||
getAttentionLevel,
|
||||
TERMINAL_STATUSES,
|
||||
TERMINAL_ACTIVITIES,
|
||||
NON_RESTORABLE_STATUSES,
|
||||
type DashboardSession,
|
||||
} from "../types";
|
||||
import {
|
||||
TERMINAL_STATUSES as CORE_TERMINAL_STATUSES,
|
||||
TERMINAL_ACTIVITIES as CORE_TERMINAL_ACTIVITIES,
|
||||
NON_RESTORABLE_STATUSES as CORE_NON_RESTORABLE_STATUSES,
|
||||
} from "@composio/ao-core/types";
|
||||
|
||||
// Helper to create a minimal DashboardSession for testing
|
||||
function createSession(overrides?: Partial<DashboardSession>): DashboardSession {
|
||||
|
|
@ -459,3 +470,17 @@ describe("getAttentionLevel", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("constants sync with core", () => {
|
||||
it("TERMINAL_STATUSES matches core", () => {
|
||||
expect(TERMINAL_STATUSES).toBe(CORE_TERMINAL_STATUSES);
|
||||
});
|
||||
|
||||
it("TERMINAL_ACTIVITIES matches core", () => {
|
||||
expect(TERMINAL_ACTIVITIES).toBe(CORE_TERMINAL_ACTIVITIES);
|
||||
});
|
||||
|
||||
it("NON_RESTORABLE_STATUSES matches core", () => {
|
||||
expect(NON_RESTORABLE_STATUSES).toBe(CORE_NON_RESTORABLE_STATUSES);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ import {
|
|||
ACTIVITY_STATE,
|
||||
SESSION_STATUS,
|
||||
CI_STATUS,
|
||||
TERMINAL_STATUSES,
|
||||
TERMINAL_ACTIVITIES,
|
||||
NON_RESTORABLE_STATUSES,
|
||||
type CICheck as CoreCICheck,
|
||||
type MergeReadiness,
|
||||
type CIStatus,
|
||||
|
|
@ -29,6 +32,9 @@ import {
|
|||
type ReviewDecision,
|
||||
} from "@composio/ao-core/types";
|
||||
|
||||
// Re-export for use in client components
|
||||
export { TERMINAL_STATUSES, TERMINAL_ACTIVITIES, NON_RESTORABLE_STATUSES };
|
||||
|
||||
/**
|
||||
* Attention zone priority level, ordered by human action urgency:
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in New Issue