feat(cli): wire activity events into CLI commands and supervisor lifecycle (#1654)
Adds "cli" to ActivityEventSource and emits ~30 activity events across the CLI surface so `ao events list --source cli` can answer RCA questions like: - Did AO start cleanly? When? (cli.start_invoked / cli.start_failed) - Was AO shut down gracefully or did it crash? (cli.shutdown_signal / cli.shutdown_completed / cli.shutdown_force_exit / cli.stale_running_pruned) - Did ao spawn / ao update / ao stop / ao migrate-storage fail and why? - Did the auto-restore prompt actually restore sessions? Instrumented files: - packages/core/src/activity-events.ts (cli source) - packages/cli/src/lib/shutdown.ts (signal/completed/failed/force_exit/session_kill_failed) - packages/cli/src/commands/start.ts (start_invoked/start_failed/restore_*/stop_*/daemon_*/last_stop_* /config_migrated) - packages/cli/src/commands/spawn.ts (spawn_invoked/spawn_failed) - packages/cli/src/commands/update.ts (update_invoked/update_failed) - packages/cli/src/commands/setup.ts (setup_failed) - packages/cli/src/commands/migrate-storage.ts (migration_completed/migration_failed) - packages/cli/src/commands/project.ts (project_register_failed) - packages/cli/src/lib/resolve-project.ts (project_resolve_failed/config_recovered/config_recovery_failed) - packages/cli/src/lib/running-state.ts (lock_timeout/stale_running_pruned) - packages/cli/src/lib/credential-resolver.ts (credential_load_failed) All emits are sync, never wrapped in try/catch (recordActivityEvent never throws), and put raw error text in data.errorMessage (not summary, which is FTS-indexed and not credential-sanitized). Tests: - 16 new instrumentation tests across shutdown, migrate-storage, update, resolve-project, and start/stop action paths covering MUST emits. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
7c46dc92a4
commit
fa644a3ce4
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@aoagents/ao-core": minor
|
||||
"@aoagents/ao-cli": minor
|
||||
---
|
||||
|
||||
Wire CLI activity events into `ao start`, `ao stop`, `ao spawn`, `ao update`, `ao setup`, `ao migrate-storage`, and shared CLI helpers. `ao events list --source cli` now answers RCA questions like "did AO start cleanly?", "was AO killed or did it crash?", and "did `ao spawn`/`ao stop` fail and why?". Adds `"cli"` to the `ActivityEventSource` union and 30+ event-emit sites covering startup, graceful and forced shutdown, restore, project resolution, config recovery, and migration paths.
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Tests for migrate-storage activity-event instrumentation (issue #1654).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { recordActivityEvent } from "@aoagents/ao-core";
|
||||
|
||||
const { mockMigrateStorage, mockRollbackStorage } = vi.hoisted(() => ({
|
||||
mockMigrateStorage: vi.fn(),
|
||||
mockRollbackStorage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
migrateStorage: (...args: unknown[]) => mockMigrateStorage(...args),
|
||||
rollbackStorage: (...args: unknown[]) => mockRollbackStorage(...args),
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { registerMigrateStorage } from "../../src/commands/migrate-storage.js";
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
describe("ao migrate-storage — activity events", () => {
|
||||
let program: Command;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
mockMigrateStorage.mockReset();
|
||||
mockRollbackStorage.mockReset();
|
||||
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
registerMigrateStorage(program);
|
||||
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
consoleErrSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleErrSpy.mockRestore();
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("emits cli.migration_failed when migrateStorage throws", async () => {
|
||||
mockMigrateStorage.mockRejectedValue(new Error("disk full"));
|
||||
|
||||
await program.parseAsync(["node", "ao", "migrate-storage"]);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.migration_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
rollback: false,
|
||||
errorMessage: "disk full",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.migration_failed when rollbackStorage throws", async () => {
|
||||
mockRollbackStorage.mockRejectedValue(new Error("rollback boom"));
|
||||
|
||||
await program.parseAsync(["node", "ao", "migrate-storage", "--rollback"]);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.migration_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
rollback: true,
|
||||
errorMessage: "rollback boom",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -23,6 +23,7 @@ vi.mock("@aoagents/ao-core", () => ({
|
|||
isPortfolioEnabled: () => true,
|
||||
getPortfolio: mockGetPortfolio,
|
||||
getPortfolioSessionCounts: mockGetPortfolioSessionCounts,
|
||||
recordActivityEvent: vi.fn(),
|
||||
registerProject: mockRegisterProject,
|
||||
unregisterProject: mockUnregisterProject,
|
||||
loadPreferences: mockLoadPreferences,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ vi.mock("@aoagents/ao-core", () => ({
|
|||
findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args),
|
||||
isCanonicalGlobalConfigPath: (configPath: string | undefined) =>
|
||||
configPath === join(homedir(), ".agent-orchestrator", "config.yaml"),
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,451 @@
|
|||
/**
|
||||
* Tests for start.ts activity-event instrumentation (issue #1654).
|
||||
*
|
||||
* Covers MUST emits in registerStop and the start action that don't
|
||||
* require running the full startup pipeline:
|
||||
* - cli.stop_invoked (start of ao stop action)
|
||||
* - cli.stop_failed (outer catch of ao stop action)
|
||||
* - cli.stop_session_failed (per-session kill failure during ao stop)
|
||||
* - cli.daemon_killed (SIGTERM sent to parent ao start)
|
||||
* - cli.start_failed (outer) (outer catch of ao start action)
|
||||
* - cli.restore_session_failed (per-session restore failure)
|
||||
*
|
||||
* cli.start_invoked, cli.start_failed (orchestrator_setup / supervisor_start)
|
||||
* are exercised by the existing start.test.ts infrastructure; this file
|
||||
* focuses on emits that are reachable with a small deps surface.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Command } from "commander";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
mockSessionManager,
|
||||
mockGetRunning,
|
||||
mockUnregister,
|
||||
mockWriteLastStop,
|
||||
mockReadLastStop,
|
||||
mockClearLastStop,
|
||||
mockAcquireStartupLock,
|
||||
mockIsAlreadyRunning,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSessionManager: {
|
||||
list: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
ensureOrchestrator: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
mockGetRunning: vi.fn(),
|
||||
mockUnregister: vi.fn(),
|
||||
mockWriteLastStop: vi.fn(),
|
||||
mockReadLastStop: vi.fn(),
|
||||
mockClearLastStop: vi.fn(),
|
||||
mockAcquireStartupLock: vi.fn(),
|
||||
mockIsAlreadyRunning: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: async () => mockSessionManager,
|
||||
getPluginRegistry: async () => ({ register: vi.fn(), get: () => null }),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/running-state.js", () => ({
|
||||
acquireStartupLock: (...args: unknown[]) => mockAcquireStartupLock(...args),
|
||||
isAlreadyRunning: (...args: unknown[]) => mockIsAlreadyRunning(...args),
|
||||
getRunning: (...args: unknown[]) => mockGetRunning(...args),
|
||||
register: vi.fn(),
|
||||
unregister: (...args: unknown[]) => mockUnregister(...args),
|
||||
removeProjectFromRunning: vi.fn(),
|
||||
addProjectToRunning: vi.fn(),
|
||||
writeLastStop: (...args: unknown[]) => mockWriteLastStop(...args),
|
||||
readLastStop: (...args: unknown[]) => mockReadLastStop(...args),
|
||||
clearLastStop: (...args: unknown[]) => mockClearLastStop(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/lifecycle-service.js", () => ({
|
||||
stopAllLifecycleWorkers: vi.fn(),
|
||||
listLifecycleWorkers: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/project-supervisor.js", () => ({
|
||||
startProjectSupervisor: vi.fn(),
|
||||
stopProjectSupervisor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/preflight.js", () => ({
|
||||
preflight: { checkPort: vi.fn(), checkBuilt: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/web-dir.js", () => ({
|
||||
findWebDir: vi.fn().mockReturnValue("/fake/web"),
|
||||
buildDashboardEnv: vi.fn().mockResolvedValue({}),
|
||||
waitForPortAndOpen: vi.fn(),
|
||||
openUrl: vi.fn(),
|
||||
isPortAvailable: vi.fn().mockResolvedValue(true),
|
||||
findFreePort: vi.fn().mockResolvedValue(3000),
|
||||
MAX_PORT_SCAN: 100,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/dashboard-rebuild.js", () => ({
|
||||
clearStaleCacheIfNeeded: vi.fn(),
|
||||
rebuildDashboardProductionArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
exec: vi.fn().mockResolvedValue({ stdout: "" }),
|
||||
execSilent: vi.fn().mockResolvedValue({ stdout: "" }),
|
||||
git: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/bun-tmp-janitor.js", () => ({
|
||||
startBunTmpJanitor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/daemon.js", () => ({
|
||||
attachToDaemon: vi.fn(),
|
||||
killExistingDaemon: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/caller-context.js", () => ({
|
||||
isHumanCaller: () => false,
|
||||
getCallerType: () => "automation",
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/detect-env.js", () => ({
|
||||
detectEnvironment: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/detect-agent.js", () => ({
|
||||
detectAgentRuntime: vi.fn(),
|
||||
detectAvailableAgents: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/git-utils.js", () => ({
|
||||
detectDefaultBranch: vi.fn().mockResolvedValue("main"),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/prompts.js", () => ({
|
||||
promptConfirm: vi.fn().mockResolvedValue(false),
|
||||
promptSelect: vi.fn(),
|
||||
promptText: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/install-helpers.js", () => ({
|
||||
canPromptForInstall: vi.fn().mockReturnValue(false),
|
||||
genericInstallHints: vi.fn().mockReturnValue([]),
|
||||
askYesNo: vi.fn().mockResolvedValue(false),
|
||||
runInteractiveCommand: vi.fn(),
|
||||
tryInstallWithAttempts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/startup-preflight.js", () => ({
|
||||
ensureGit: vi.fn(),
|
||||
runtimePreflight: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shutdown.js", () => ({
|
||||
installShutdownHandlers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/resolve-project.js", () => ({
|
||||
resolveOrCreateProject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/project-resolution.js", () => ({
|
||||
findProjectForDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/repo-utils.js", () => ({
|
||||
extractOwnerRepo: vi.fn(),
|
||||
isValidRepoString: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/project-detection.js", () => ({
|
||||
detectProjectType: vi.fn(),
|
||||
generateRulesFromTemplates: vi.fn(),
|
||||
formatProjectTypeForDisplay: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/cli-errors.js", () => ({
|
||||
formatCommandError: vi.fn((err: unknown) => String(err)),
|
||||
}));
|
||||
|
||||
import { recordActivityEvent } from "@aoagents/ao-core";
|
||||
import { registerStart, registerStop } from "../../src/commands/start.js";
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
function buildProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerStart(program);
|
||||
registerStop(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("ao stop — activity events", () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
mockGetRunning.mockReset();
|
||||
mockSessionManager.list.mockReset();
|
||||
mockSessionManager.kill.mockReset();
|
||||
mockUnregister.mockReset();
|
||||
mockWriteLastStop.mockReset();
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emits cli.stop_invoked at the start of the action", async () => {
|
||||
// Force a fast failure so the action exits quickly after emitting stop_invoked.
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
// Make loadConfig throw so we hit the outer catch
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
loadConfig: () => {
|
||||
throw new Error("config not found");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("../../src/commands/start.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "stop"])).rejects.toThrow();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.stop_invoked",
|
||||
source: "cli",
|
||||
}),
|
||||
);
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
|
||||
it("emits cli.stop_failed when loadConfig throws", async () => {
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
loadConfig: () => {
|
||||
throw new Error("config blew up");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("../../src/commands/start.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "stop"])).rejects.toThrow();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.stop_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ errorMessage: "config blew up" }),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
|
||||
it("emits cli.daemon_killed when SIGTERM is sent to a running daemon", async () => {
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 99999,
|
||||
configPath: "/tmp/x.yaml",
|
||||
port: 3000,
|
||||
startedAt: new Date().toISOString(),
|
||||
projects: ["my-app"],
|
||||
});
|
||||
mockSessionManager.list.mockResolvedValue([]);
|
||||
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
loadConfig: () => ({
|
||||
configPath: "/tmp/x.yaml",
|
||||
port: 3000,
|
||||
projects: { "my-app": { name: "my-app", path: "/tmp/my-app" } },
|
||||
defaults: {},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("../../src/commands/start.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "ao", "stop"]);
|
||||
} catch {
|
||||
// ao stop may exit; we just want the events
|
||||
}
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.daemon_killed",
|
||||
source: "cli",
|
||||
data: expect.objectContaining({ pid: 99999 }),
|
||||
}),
|
||||
);
|
||||
|
||||
killSpy.mockRestore();
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
|
||||
it("emits cli.stop_session_failed when sm.kill throws during ao stop", async () => {
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 99999,
|
||||
configPath: "/tmp/x.yaml",
|
||||
port: 3000,
|
||||
startedAt: new Date().toISOString(),
|
||||
projects: ["my-app"],
|
||||
});
|
||||
mockSessionManager.list.mockResolvedValue([
|
||||
{
|
||||
id: "sess-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
},
|
||||
]);
|
||||
mockSessionManager.kill.mockRejectedValue(new Error("kill timeout"));
|
||||
vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
loadConfig: () => ({
|
||||
configPath: "/tmp/x.yaml",
|
||||
port: 3000,
|
||||
projects: { "my-app": { name: "my-app", path: "/tmp/my-app" } },
|
||||
defaults: {},
|
||||
}),
|
||||
// isTerminalSession returns false so the session is treated as active
|
||||
isTerminalSession: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("../../src/commands/start.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "ao", "stop"]);
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.stop_session_failed",
|
||||
source: "cli",
|
||||
level: "warn",
|
||||
sessionId: "sess-1",
|
||||
data: expect.objectContaining({ errorMessage: "kill timeout" }),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ao start — activity events (failure paths)", () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
mockAcquireStartupLock.mockReset();
|
||||
mockIsAlreadyRunning.mockReset();
|
||||
mockAcquireStartupLock.mockResolvedValue(() => undefined);
|
||||
mockIsAlreadyRunning.mockResolvedValue(null);
|
||||
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emits cli.start_failed with reason 'outer' when resolveOrCreateProject throws", async () => {
|
||||
const resolveProjectMod = await import("../../src/lib/resolve-project.js");
|
||||
vi.mocked(resolveProjectMod.resolveOrCreateProject).mockRejectedValue(
|
||||
new Error("project resolution exploded"),
|
||||
);
|
||||
|
||||
const program = buildProgram();
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "ao", "start"]);
|
||||
} catch {
|
||||
// process.exit(1) throws in the spy
|
||||
}
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.start_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
reason: "outer",
|
||||
errorMessage: "project resolution exploded",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* Tests for update.ts activity-event instrumentation (issue #1654).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { recordActivityEvent } from "@aoagents/ao-core";
|
||||
|
||||
const { mockRunRepoScript } = vi.hoisted(() => ({
|
||||
mockRunRepoScript: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/script-runner.js", () => ({
|
||||
runRepoScript: (...args: unknown[]) => mockRunRepoScript(...args),
|
||||
}));
|
||||
|
||||
const {
|
||||
mockDetectInstallMethod,
|
||||
mockCheckForUpdate,
|
||||
mockInvalidateCache,
|
||||
mockGetCurrentVersion,
|
||||
mockGetUpdateCommand,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDetectInstallMethod: vi.fn(() => "git" as const),
|
||||
mockCheckForUpdate: vi.fn(),
|
||||
mockInvalidateCache: vi.fn(),
|
||||
mockGetCurrentVersion: vi.fn(() => "0.2.2"),
|
||||
mockGetUpdateCommand: vi.fn(() => "npm install -g @aoagents/ao@latest"),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/update-check.js", () => ({
|
||||
detectInstallMethod: () => mockDetectInstallMethod(),
|
||||
checkForUpdate: (...args: unknown[]) => mockCheckForUpdate(...args),
|
||||
invalidateCache: () => mockInvalidateCache(),
|
||||
getCurrentVersion: () => mockGetCurrentVersion(),
|
||||
getUpdateCommand: (...args: unknown[]) => mockGetUpdateCommand(...args),
|
||||
}));
|
||||
|
||||
const { mockPromptConfirm } = vi.hoisted(() => ({
|
||||
mockPromptConfirm: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/prompts.js", () => ({
|
||||
promptConfirm: (...args: unknown[]) => mockPromptConfirm(...args),
|
||||
}));
|
||||
|
||||
const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() }));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual("node:child_process");
|
||||
return { ...actual, spawn: (...args: unknown[]) => mockSpawn(...args) };
|
||||
});
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { registerUpdate } from "../../src/commands/update.js";
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
function createMockChild(exitCode: number | null, signal?: NodeJS.Signals): EventEmitter {
|
||||
const child = new EventEmitter();
|
||||
setTimeout(() => child.emit("exit", exitCode, signal ?? null), 0);
|
||||
return child;
|
||||
}
|
||||
|
||||
describe("ao update — activity events", () => {
|
||||
let program: Command;
|
||||
let origStdinTTY: boolean | undefined;
|
||||
let origStdoutTTY: boolean | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
registerUpdate(program);
|
||||
mockRunRepoScript.mockReset();
|
||||
mockDetectInstallMethod.mockReturnValue("git");
|
||||
mockCheckForUpdate.mockReset();
|
||||
mockInvalidateCache.mockReset();
|
||||
mockPromptConfirm.mockReset();
|
||||
mockPromptConfirm.mockResolvedValue(true);
|
||||
mockSpawn.mockReset();
|
||||
origStdinTTY = process.stdin.isTTY;
|
||||
origStdoutTTY = process.stdout.isTTY;
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: origStdinTTY, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: origStdoutTTY, configurable: true });
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when ao-update.sh exits non-zero (git path)", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("git");
|
||||
mockRunRepoScript.mockResolvedValue(2);
|
||||
|
||||
// process.exit is mocked to throw — the first `process.exit(2)` triggers
|
||||
// the throw, which is then re-caught and emits a second event before the
|
||||
// final exit. The instrumentation event for the non-zero exit is what
|
||||
// matters; whichever final exit code propagates is incidental.
|
||||
await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow(
|
||||
/process\.exit/,
|
||||
);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ method: "git", exitCode: 2 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when ao-update.sh script is missing (git path)", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("git");
|
||||
mockRunRepoScript.mockRejectedValue(
|
||||
new Error("Script not found: ao-update.sh"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "ao", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ method: "git", reason: "script_missing" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when npm install exits non-zero (npm path)", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
mockCheckForUpdate.mockResolvedValue({
|
||||
currentVersion: "0.2.2",
|
||||
latestVersion: "0.3.0",
|
||||
isOutdated: true,
|
||||
installMethod: "npm-global" as const,
|
||||
recommendedCommand: "npm install -g @aoagents/ao@latest",
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
||||
mockSpawn.mockReturnValue(createMockChild(1));
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "ao", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ method: "npm-global", exitCode: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* Tests for resolve-project.ts activity-event instrumentation (issue #1654).
|
||||
*
|
||||
* Covers MUST emits:
|
||||
* - cli.project_resolve_failed (clone failure inside fromUrl)
|
||||
* - cli.config_recovery_failed (registerFlatConfig returns null)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { recordActivityEvent } from "@aoagents/ao-core";
|
||||
import type { ParsedRepoUrl } from "@aoagents/ao-core";
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
recordActivityEvent: vi.fn(),
|
||||
// resolveCloneTarget points to a tmp dir; isRepoAlreadyCloned forces the
|
||||
// clone path so cloneRepo is invoked (and can throw).
|
||||
resolveCloneTarget: () => "/tmp/__ao_test_clone_target__",
|
||||
isRepoAlreadyCloned: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/startup-preflight.js", () => ({
|
||||
ensureGit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/web-dir.js", () => ({
|
||||
findFreePort: vi.fn().mockResolvedValue(3000),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
git: vi.fn().mockResolvedValue({ stdout: "" }),
|
||||
}));
|
||||
|
||||
import { resolveOrCreateProject } from "../../src/lib/resolve-project.js";
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
describe("resolve-project — activity events", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
});
|
||||
|
||||
it("emits cli.project_resolve_failed when cloneRepo throws (URL into running daemon)", async () => {
|
||||
const cloneRepo = vi.fn(async (_parsed: ParsedRepoUrl, _target: string, _cwd: string) => {
|
||||
throw new Error("network down");
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveOrCreateProject(
|
||||
"https://github.com/foo/bar",
|
||||
{
|
||||
addProjectToConfig: vi.fn(),
|
||||
autoCreateConfig: vi.fn(),
|
||||
resolveProject: vi.fn(),
|
||||
resolveProjectByRepo: vi.fn(),
|
||||
registerFlatConfig: vi.fn().mockResolvedValue(null),
|
||||
cloneRepo,
|
||||
},
|
||||
// targetGlobalRegistry: true → exercises fromUrlIntoGlobal
|
||||
{ targetGlobalRegistry: true },
|
||||
),
|
||||
).rejects.toThrow(/Failed to clone/);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.project_resolve_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
ownerRepo: "foo/bar",
|
||||
errorMessage: "network down",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.config_recovery_failed when registerFlatConfig returns null", async () => {
|
||||
// Trigger fromCwdOrId via undefined arg; if loadConfig() throws something
|
||||
// other than ConfigNotFoundError, the recovery path runs and asks
|
||||
// registerFlatConfig to fix it.
|
||||
//
|
||||
// Here we force the recovery path to fail by stubbing the deps so that:
|
||||
// 1. autoCreateConfig is not invoked (fromCwdOrId only calls it when
|
||||
// loadConfig throws ConfigNotFoundError — we trigger a different
|
||||
// error so the registerFlatConfig branch runs).
|
||||
// 2. registerFlatConfig returns null (recovery fails).
|
||||
//
|
||||
// We can't easily make the real loadConfig() throw a non-ConfigNotFoundError
|
||||
// synchronously, so instead we patch findConfigFile via the mocked module
|
||||
// surface. The simplest, robust approach: simulate the public call shape
|
||||
// and assert that whenever `registerFlatConfig` returns null, the event
|
||||
// fires at the call site. To do that we drive the function with a
|
||||
// controlled cwd that lacks a parseable config but has a config file
|
||||
// present — replicated by stubbing findConfigFile in @aoagents/ao-core.
|
||||
|
||||
// Reach into the same module mock by re-mocking findConfigFile + loadConfig.
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
// findConfigFile returns a path so the recovery branch runs.
|
||||
findConfigFile: () => "/tmp/__ao_test_flat_config__",
|
||||
// loadConfig throws a generic Error (not ConfigNotFoundError) so the
|
||||
// catch block falls into the registerFlatConfig recovery branch.
|
||||
loadConfig: () => {
|
||||
throw new Error("malformed config");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const { resolveOrCreateProject: resolveOrCreateProjectReloaded } = await import(
|
||||
"../../src/lib/resolve-project.js"
|
||||
);
|
||||
// Re-grab the mock so cleared calls inside the doMock factory don't get lost.
|
||||
const { recordActivityEvent: reloadedRecord } = await import("@aoagents/ao-core");
|
||||
vi.mocked(reloadedRecord).mockClear();
|
||||
|
||||
await expect(
|
||||
resolveOrCreateProjectReloaded(
|
||||
undefined,
|
||||
{
|
||||
addProjectToConfig: vi.fn(),
|
||||
autoCreateConfig: vi.fn(),
|
||||
resolveProject: vi.fn(),
|
||||
resolveProjectByRepo: vi.fn(),
|
||||
registerFlatConfig: vi.fn().mockResolvedValue(null),
|
||||
cloneRepo: vi.fn(),
|
||||
},
|
||||
{},
|
||||
),
|
||||
).rejects.toThrow(/malformed config/);
|
||||
|
||||
const events = vi
|
||||
.mocked(reloadedRecord)
|
||||
.mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.config_recovery_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
configPath: "/tmp/__ao_test_flat_config__",
|
||||
errorMessage: "malformed config",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* Tests for shutdown.ts activity-event instrumentation (issue #1654).
|
||||
*
|
||||
* Each test mounts handlers via `installShutdownHandlers`, fires a signal,
|
||||
* and asserts the expected `cli.*` activity events are emitted. Real
|
||||
* `process.exit` is stubbed so the test process is not terminated by the
|
||||
* shutdown handler.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { recordActivityEvent } from "@aoagents/ao-core";
|
||||
|
||||
const {
|
||||
mockListSessions,
|
||||
mockKillSession,
|
||||
mockGetSessionManager,
|
||||
mockUnregister,
|
||||
mockWriteLastStop,
|
||||
mockStopBunTmpJanitor,
|
||||
mockStopProjectSupervisor,
|
||||
mockStopAllLifecycleWorkers,
|
||||
mockLoadConfig,
|
||||
mockIsTerminalSession,
|
||||
} = vi.hoisted(() => ({
|
||||
mockListSessions: vi.fn(),
|
||||
mockKillSession: vi.fn(),
|
||||
mockGetSessionManager: vi.fn(),
|
||||
mockUnregister: vi.fn(),
|
||||
mockWriteLastStop: vi.fn(),
|
||||
mockStopBunTmpJanitor: vi.fn(),
|
||||
mockStopProjectSupervisor: vi.fn(),
|
||||
mockStopAllLifecycleWorkers: vi.fn(),
|
||||
mockLoadConfig: vi.fn(),
|
||||
mockIsTerminalSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: (...args: unknown[]) => mockLoadConfig(...args),
|
||||
isTerminalSession: (...args: unknown[]) => mockIsTerminalSession(...args),
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: (...args: unknown[]) => mockGetSessionManager(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/lifecycle-service.js", () => ({
|
||||
stopAllLifecycleWorkers: (...args: unknown[]) => mockStopAllLifecycleWorkers(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/project-supervisor.js", () => ({
|
||||
stopProjectSupervisor: (...args: unknown[]) => mockStopProjectSupervisor(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/running-state.js", () => ({
|
||||
unregister: (...args: unknown[]) => mockUnregister(...args),
|
||||
writeLastStop: (...args: unknown[]) => mockWriteLastStop(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/bun-tmp-janitor.js", () => ({
|
||||
stopBunTmpJanitor: (...args: unknown[]) => mockStopBunTmpJanitor(...args),
|
||||
}));
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
const flushAsync = async (): Promise<void> => {
|
||||
// The shutdown handler launches an async IIFE; allow it to settle.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((r) => setImmediate(r));
|
||||
}
|
||||
};
|
||||
|
||||
describe("shutdown handlers — activity events", () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let originalListenersSigint: NodeJS.SignalsListener[];
|
||||
let originalListenersSigterm: NodeJS.SignalsListener[];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
mockListSessions.mockReset();
|
||||
mockKillSession.mockReset();
|
||||
mockGetSessionManager.mockReset();
|
||||
mockUnregister.mockReset();
|
||||
mockWriteLastStop.mockReset();
|
||||
mockStopBunTmpJanitor.mockReset();
|
||||
mockStopProjectSupervisor.mockReset();
|
||||
mockStopAllLifecycleWorkers.mockReset();
|
||||
mockLoadConfig.mockReset();
|
||||
mockIsTerminalSession.mockReset();
|
||||
|
||||
mockLoadConfig.mockReturnValue({ projects: {} });
|
||||
mockIsTerminalSession.mockReturnValue(false);
|
||||
mockGetSessionManager.mockResolvedValue({
|
||||
list: mockListSessions,
|
||||
kill: mockKillSession,
|
||||
});
|
||||
mockListSessions.mockResolvedValue([]);
|
||||
mockKillSession.mockResolvedValue({ cleaned: true });
|
||||
mockUnregister.mockResolvedValue(undefined);
|
||||
mockWriteLastStop.mockResolvedValue(undefined);
|
||||
mockStopBunTmpJanitor.mockResolvedValue(undefined);
|
||||
|
||||
// Snapshot existing signal listeners so we can restore them after each
|
||||
// test and avoid leaking handlers across tests in the same process.
|
||||
originalListenersSigint = process.listeners("SIGINT") as NodeJS.SignalsListener[];
|
||||
originalListenersSigterm = process.listeners("SIGTERM") as NodeJS.SignalsListener[];
|
||||
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
// Throw a sentinel to short-circuit the async IIFE without leaving
|
||||
// the test process in an unknown state.
|
||||
return undefined as never;
|
||||
}) as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
|
||||
// Restore signal listeners
|
||||
process.removeAllListeners("SIGINT");
|
||||
process.removeAllListeners("SIGTERM");
|
||||
for (const l of originalListenersSigint) process.on("SIGINT", l);
|
||||
for (const l of originalListenersSigterm) process.on("SIGTERM", l);
|
||||
});
|
||||
|
||||
it("emits cli.shutdown_signal when SIGINT is received", async () => {
|
||||
const { installShutdownHandlers } = await import("../../src/lib/shutdown.js");
|
||||
installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" });
|
||||
|
||||
process.emit("SIGINT", "SIGINT");
|
||||
await flushAsync();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.shutdown_signal",
|
||||
source: "cli",
|
||||
projectId: "p1",
|
||||
data: expect.objectContaining({ signal: "SIGINT", exitCode: 130 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.shutdown_completed after clean shutdown", async () => {
|
||||
const { installShutdownHandlers } = await import("../../src/lib/shutdown.js");
|
||||
installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" });
|
||||
|
||||
process.emit("SIGTERM", "SIGTERM");
|
||||
await flushAsync();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.shutdown_completed",
|
||||
source: "cli",
|
||||
projectId: "p1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.shutdown_failed when shutdown body throws before completion", async () => {
|
||||
const { installShutdownHandlers } = await import("../../src/lib/shutdown.js");
|
||||
mockGetSessionManager.mockRejectedValue(new Error("getSessionManager boom"));
|
||||
|
||||
installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" });
|
||||
|
||||
process.emit("SIGTERM", "SIGTERM");
|
||||
await flushAsync();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.shutdown_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ errorMessage: "getSessionManager boom" }),
|
||||
}),
|
||||
);
|
||||
// Failure path should NOT emit shutdown_completed
|
||||
const completedEvents = events.filter((e) => e.kind === "cli.shutdown_completed");
|
||||
expect(completedEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits cli.shutdown_force_exit when the 10s timer fires", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { installShutdownHandlers } = await import("../../src/lib/shutdown.js");
|
||||
// Hang the async cleanup so the force-exit timer wins.
|
||||
mockGetSessionManager.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" });
|
||||
|
||||
process.emit("SIGINT", "SIGINT");
|
||||
// Advance past the 10s timeout
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.shutdown_force_exit",
|
||||
source: "cli",
|
||||
level: "warn",
|
||||
data: expect.objectContaining({ timeoutMs: 10_000, exitCode: 130 }),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Command } from "commander";
|
||||
import chalk from "chalk";
|
||||
import { migrateStorage, rollbackStorage } from "@aoagents/ao-core";
|
||||
import { migrateStorage, recordActivityEvent, rollbackStorage } from "@aoagents/ao-core";
|
||||
|
||||
export function registerMigrateStorage(program: Command): void {
|
||||
program
|
||||
|
|
@ -19,6 +19,13 @@ export function registerMigrateStorage(program: Command): void {
|
|||
dryRun: opts.dryRun,
|
||||
log: (msg) => console.log(msg),
|
||||
});
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.migration_completed",
|
||||
level: "info",
|
||||
summary: `storage rollback completed`,
|
||||
data: { rollback: true, dryRun: opts.dryRun === true },
|
||||
});
|
||||
} else {
|
||||
const result = await migrateStorage({
|
||||
dryRun: opts.dryRun,
|
||||
|
|
@ -31,8 +38,30 @@ export function registerMigrateStorage(program: Command): void {
|
|||
} else {
|
||||
console.log(chalk.green("\nMigration complete."));
|
||||
}
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.migration_completed",
|
||||
level: "info",
|
||||
summary: `storage migration completed (${result.projects} project(s))`,
|
||||
data: {
|
||||
rollback: false,
|
||||
dryRun: opts.dryRun === true,
|
||||
force: opts.force === true,
|
||||
projects: result.projects,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.migration_failed",
|
||||
level: "error",
|
||||
summary: `storage migration failed`,
|
||||
data: {
|
||||
rollback: opts.rollback === true,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
chalk.red(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getPortfolio,
|
||||
getPortfolioSessionCounts,
|
||||
isPortfolioEnabled,
|
||||
recordActivityEvent,
|
||||
registerProject,
|
||||
unregisterProject,
|
||||
loadPreferences,
|
||||
|
|
@ -94,18 +95,48 @@ export function registerProjectCommand(program: Command): void {
|
|||
const existingConfigPath = candidatePaths.find((candidate) => existsSync(candidate));
|
||||
|
||||
if (!existingConfigPath) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_register_failed",
|
||||
level: "warn",
|
||||
summary: `ao project add: no agent-orchestrator config found`,
|
||||
data: { resolvedPath, reason: "no_config_found" },
|
||||
});
|
||||
console.error(chalk.red(`No agent-orchestrator.yaml found at ${resolvedPath}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
loadConfig(existingConfigPath);
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_register_failed",
|
||||
level: "warn",
|
||||
summary: `ao project add: found old-format config requiring migration`,
|
||||
data: {
|
||||
resolvedPath,
|
||||
configPath: existingConfigPath,
|
||||
reason: "old_format",
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Found old-format config at ${existingConfigPath}. Run \`ao start\` in that project to migrate it before using \`ao project add\`.`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_register_failed",
|
||||
level: "error",
|
||||
summary: `ao project add: config load failed`,
|
||||
data: {
|
||||
resolvedPath,
|
||||
configPath: existingConfigPath,
|
||||
reason: "load_error",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Found agent-orchestrator config at ${existingConfigPath}, but it could not be loaded: ${error instanceof Error ? error.message : String(error)}`,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ import { join } from "node:path";
|
|||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { parse as yamlParse, parseDocument } from "yaml";
|
||||
import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core";
|
||||
import {
|
||||
CONFIG_SCHEMA_URL,
|
||||
findConfigFile,
|
||||
isCanonicalGlobalConfigPath,
|
||||
recordActivityEvent,
|
||||
} from "@aoagents/ao-core";
|
||||
import {
|
||||
probeGateway,
|
||||
validateToken,
|
||||
|
|
@ -512,6 +517,16 @@ export function registerSetup(program: Command): void {
|
|||
try {
|
||||
await runSetupAction(opts);
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.setup_failed",
|
||||
level: "error",
|
||||
summary: `ao setup openclaw failed`,
|
||||
data: {
|
||||
aborted: err instanceof SetupAbortedError,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (err instanceof SetupAbortedError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
|
|
@ -574,6 +589,14 @@ export async function runSetupAction(opts: SetupOptions): Promise<void> {
|
|||
const openclawConfigWritten = writeOpenClawJsonConfig(resolved.token);
|
||||
if (openclawConfigWritten && nonInteractive) {
|
||||
console.log(chalk.green("✓ Wrote hooks config to ~/.openclaw/openclaw.json"));
|
||||
} else if (!openclawConfigWritten) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.setup_failed",
|
||||
level: "warn",
|
||||
summary: `failed to write ~/.openclaw/openclaw.json`,
|
||||
data: { reason: "openclaw_json_write_failed" },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Write shell export --------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { Command } from "commander";
|
|||
import { resolve } from "node:path";
|
||||
import {
|
||||
loadConfig,
|
||||
recordActivityEvent,
|
||||
resolveSpawnTarget,
|
||||
TERMINAL_STATUSES,
|
||||
type OrchestratorConfig,
|
||||
|
|
@ -216,6 +217,20 @@ async function spawnSession(
|
|||
throw new Error("Prompt must be at most 4096 characters");
|
||||
}
|
||||
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.spawn_invoked",
|
||||
level: "info",
|
||||
summary: `ao spawn invoked${issueId ? ` for issue ${issueId}` : ""}`,
|
||||
data: {
|
||||
issueId: issueId ?? null,
|
||||
agent: agent ?? null,
|
||||
hasPrompt: !!sanitizedPrompt,
|
||||
claimPr: claimOptions?.claimPr ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const session = await sm.spawn({
|
||||
projectId,
|
||||
issueId,
|
||||
|
|
@ -262,6 +277,18 @@ async function spawnSession(
|
|||
console.log(`SESSION=${session.id}`);
|
||||
} catch (err) {
|
||||
spinner.fail("Failed to create or initialize session");
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.spawn_failed",
|
||||
level: "error",
|
||||
summary: `ao spawn failed${issueId ? ` for issue ${issueId}` : ""}`,
|
||||
data: {
|
||||
issueId: issueId ?? null,
|
||||
agent: agent ?? null,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -404,6 +431,17 @@ export function registerBatchSpawn(program: Command): void {
|
|||
await runSpawnPreflight(config, groupProjectId);
|
||||
await ensureAOPollingProject(groupProjectId);
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: groupProjectId,
|
||||
source: "cli",
|
||||
kind: "cli.spawn_failed",
|
||||
level: "error",
|
||||
summary: `batch-spawn preflight failed for group`,
|
||||
data: {
|
||||
batchSize: items.length,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
findPidByPort,
|
||||
killProcessTree,
|
||||
loadLocalProjectConfigDetailed,
|
||||
recordActivityEvent,
|
||||
registerProjectInGlobalConfig,
|
||||
getGlobalConfigPath,
|
||||
type OrchestratorConfig,
|
||||
|
|
@ -154,6 +155,15 @@ async function registerFlatConfig(configPath: string): Promise<string | null> {
|
|||
...(repo ? { repo } : {}),
|
||||
});
|
||||
|
||||
recordActivityEvent({
|
||||
projectId: registeredProjectId,
|
||||
source: "cli",
|
||||
kind: "cli.config_migrated",
|
||||
level: "info",
|
||||
summary: `flat config registered into global config`,
|
||||
data: { projectPath, configPath },
|
||||
});
|
||||
|
||||
console.log(chalk.green(` ✓ Registered "${registeredProjectId}"\n`));
|
||||
return registeredProjectId;
|
||||
}
|
||||
|
|
@ -914,6 +924,17 @@ async function runStartup(
|
|||
}
|
||||
} catch (err) {
|
||||
spinner.fail("Orchestrator setup failed");
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.start_failed",
|
||||
level: "error",
|
||||
summary: `orchestrator setup failed`,
|
||||
data: {
|
||||
reason: "orchestrator_setup",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (dashboardProcess) {
|
||||
dashboardProcess.kill();
|
||||
}
|
||||
|
|
@ -931,6 +952,17 @@ async function runStartup(
|
|||
spinner.succeed("Lifecycle project supervisor started");
|
||||
} catch (err) {
|
||||
spinner.fail("Project supervisor failed to start");
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.start_failed",
|
||||
level: "error",
|
||||
summary: `project supervisor failed to start`,
|
||||
data: {
|
||||
reason: "supervisor_start",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (dashboardProcess) {
|
||||
dashboardProcess.kill();
|
||||
}
|
||||
|
|
@ -979,6 +1011,17 @@ async function runStartup(
|
|||
if (allRestoreSessions.length > 0) {
|
||||
const shouldRestore = await promptConfirm("Restore these sessions?", true);
|
||||
if (shouldRestore) {
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.restore_started",
|
||||
level: "info",
|
||||
summary: `restoring ${allRestoreSessions.length} session(s) from last-stop`,
|
||||
data: {
|
||||
sessionCount: allRestoreSessions.length,
|
||||
stoppedAt: lastStop.stoppedAt,
|
||||
},
|
||||
});
|
||||
// Use global config so the session manager can see all projects
|
||||
let restoreConfig = config;
|
||||
if (otherProjects.length > 0) {
|
||||
|
|
@ -1003,11 +1046,32 @@ async function runStartup(
|
|||
restoredCount++;
|
||||
} catch (err) {
|
||||
failedSessionIds.add(sessionId);
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
sessionId,
|
||||
source: "cli",
|
||||
kind: "cli.restore_session_failed",
|
||||
level: "warn",
|
||||
summary: `failed to restore session`,
|
||||
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
warnings.push(
|
||||
` Warning: could not restore ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.restore_completed",
|
||||
level: "info",
|
||||
summary: `restored ${restoredCount}/${allRestoreSessions.length} session(s)`,
|
||||
data: {
|
||||
requested: allRestoreSessions.length,
|
||||
restored: restoredCount,
|
||||
failed: failedSessionIds.size,
|
||||
},
|
||||
});
|
||||
if (restoredCount === allRestoreSessions.length) {
|
||||
restoreSpinner.succeed(
|
||||
`Restored ${restoredCount}/${allRestoreSessions.length} session(s)`,
|
||||
|
|
@ -1060,7 +1124,15 @@ async function runStartup(
|
|||
await clearLastStop();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.last_stop_read_failed",
|
||||
level: "warn",
|
||||
summary: `failed to read or process last-stop state during startup`,
|
||||
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
// Non-fatal: don't block startup if last-stop handling fails
|
||||
}
|
||||
}
|
||||
|
|
@ -1434,6 +1506,13 @@ export function registerStart(program: Command): void {
|
|||
// Resolve happens below; the suffix mutation runs after.
|
||||
startNewOrchestrator = true;
|
||||
} else if (choice === "restart") {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.daemon_restart",
|
||||
level: "info",
|
||||
summary: `user chose restart, killing existing daemon`,
|
||||
data: { existingPid: running.pid, existingPort: running.port },
|
||||
});
|
||||
await killExistingDaemon(running);
|
||||
console.log(chalk.yellow("\n Stopped existing instance. Restarting...\n"));
|
||||
running = null;
|
||||
|
|
@ -1568,6 +1647,18 @@ export function registerStart(program: Command): void {
|
|||
startedAt: new Date().toISOString(),
|
||||
projects: listLifecycleWorkers(),
|
||||
});
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.start_invoked",
|
||||
level: "info",
|
||||
summary: `ao start completed: registered on port ${actualPort}`,
|
||||
data: {
|
||||
pid: process.pid,
|
||||
port: actualPort,
|
||||
projects: listLifecycleWorkers(),
|
||||
},
|
||||
});
|
||||
unlockStartup();
|
||||
|
||||
// Start the Bun-extracted /tmp/.*.{so,dylib} janitor once per AO
|
||||
|
|
@ -1594,6 +1685,16 @@ export function registerStart(program: Command): void {
|
|||
// restore, unregister, then exit. See lib/shutdown.ts.
|
||||
installShutdownHandlers({ configPath: config.configPath, projectId });
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.start_failed",
|
||||
level: "error",
|
||||
summary: `ao start action failed`,
|
||||
data: {
|
||||
reason: "outer",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (err instanceof Error) {
|
||||
console.error(chalk.red("\nError:"), err.message);
|
||||
} else {
|
||||
|
|
@ -1665,6 +1766,17 @@ export function registerStop(program: Command): void {
|
|||
.option("--purge-session", "Delete mapped OpenCode session when stopping")
|
||||
.option("--all", "Stop all running AO instances")
|
||||
.action(async (projectArg?: string, opts: { purgeSession?: boolean; all?: boolean } = {}) => {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.stop_invoked",
|
||||
level: "info",
|
||||
summary: `ao stop invoked${projectArg ? ` for ${projectArg}` : ""}`,
|
||||
data: {
|
||||
projectArg: projectArg ?? null,
|
||||
all: opts.all === true,
|
||||
purgeSession: opts.purgeSession === true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
// Check running.json first
|
||||
const running = await getRunning();
|
||||
|
|
@ -1740,6 +1852,15 @@ export function registerStop(program: Command): void {
|
|||
killedSessionIds.push(session.id);
|
||||
}
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId ?? _projectId,
|
||||
sessionId: session.id,
|
||||
source: "cli",
|
||||
kind: "cli.stop_session_failed",
|
||||
level: "warn",
|
||||
summary: `failed to kill session during ao stop`,
|
||||
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
warnings.push(
|
||||
` Warning: failed to stop ${session.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
|
|
@ -1784,12 +1905,27 @@ export function registerStop(program: Command): void {
|
|||
otherProjects.push({ projectId: pid, sessionIds: ids });
|
||||
}
|
||||
|
||||
const targetSessionIds = killedSessionIds.filter((id) =>
|
||||
targetActive.some((s) => s.id === id),
|
||||
);
|
||||
await writeLastStop({
|
||||
stoppedAt: new Date().toISOString(),
|
||||
projectId: _projectId,
|
||||
sessionIds: killedSessionIds.filter((id) => targetActive.some((s) => s.id === id)),
|
||||
sessionIds: targetSessionIds,
|
||||
otherProjects: otherProjects.length > 0 ? otherProjects : undefined,
|
||||
});
|
||||
recordActivityEvent({
|
||||
projectId: _projectId,
|
||||
source: "cli",
|
||||
kind: "cli.last_stop_written",
|
||||
level: "info",
|
||||
summary: `last-stop state written with ${killedSessionIds.length} session(s)`,
|
||||
data: {
|
||||
targetSessionCount: targetSessionIds.length,
|
||||
otherProjectCount: otherProjects.length,
|
||||
totalKilled: killedSessionIds.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(
|
||||
|
|
@ -1815,7 +1951,29 @@ export function registerStop(program: Command): void {
|
|||
// protocol so node-pty disposes ConPTY gracefully (avoids WER
|
||||
// 0x800700e8). No-op on non-Windows.
|
||||
await sweepWindowsPtyHostsBeforeParentKill();
|
||||
await killProcessTree(running.pid, "SIGTERM");
|
||||
try {
|
||||
await killProcessTree(running.pid, "SIGTERM");
|
||||
recordActivityEvent({
|
||||
projectId: _projectId,
|
||||
source: "cli",
|
||||
kind: "cli.daemon_killed",
|
||||
level: "info",
|
||||
summary: `SIGTERM sent to parent ao start`,
|
||||
data: { pid: running.pid, port: running.port },
|
||||
});
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: _projectId,
|
||||
source: "cli",
|
||||
kind: "cli.daemon_killed",
|
||||
level: "warn",
|
||||
summary: `parent ao start was already dead`,
|
||||
data: {
|
||||
pid: running.pid,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
await unregister();
|
||||
}
|
||||
await stopDashboard(running?.port ?? port);
|
||||
|
|
@ -1833,6 +1991,16 @@ export function registerStop(program: Command): void {
|
|||
console.log(chalk.dim(` Projects: ${Object.keys(config.projects).join(", ")}\n`));
|
||||
}
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.stop_failed",
|
||||
level: "error",
|
||||
summary: `ao stop action failed`,
|
||||
data: {
|
||||
projectArg: projectArg ?? null,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (err instanceof Error) {
|
||||
console.error(chalk.red("\nError:"), err.message);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
isWindows,
|
||||
loadConfig,
|
||||
loadGlobalConfig,
|
||||
recordActivityEvent,
|
||||
type Session,
|
||||
} from "@aoagents/ao-core";
|
||||
import { runRepoScript } from "../lib/script-runner.js";
|
||||
|
|
@ -83,6 +84,14 @@ export function registerUpdate(program: Command): void {
|
|||
|
||||
const method = detectInstallMethod();
|
||||
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_invoked",
|
||||
level: "info",
|
||||
summary: `ao update invoked (method: ${method})`,
|
||||
data: { method, options: opts },
|
||||
});
|
||||
|
||||
// Reject git-only flags up front when the install isn't a git source.
|
||||
// Without this, users copy/pasting `ao update --skip-smoke` from older
|
||||
// docs would silently no-op on npm/pnpm/bun installs (the flag would be
|
||||
|
|
@ -225,6 +234,13 @@ async function handleGitUpdate(opts: {
|
|||
try {
|
||||
const exitCode = await runRepoScript("ao-update.sh", args);
|
||||
if (exitCode !== 0) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (git) failed: ao-update.sh exited non-zero`,
|
||||
data: { method: "git", exitCode },
|
||||
});
|
||||
process.exit(exitCode);
|
||||
}
|
||||
invalidateCache();
|
||||
|
|
@ -233,6 +249,13 @@ async function handleGitUpdate(opts: {
|
|||
error instanceof Error &&
|
||||
error.message.includes("Script not found: ao-update.sh")
|
||||
) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (git) failed: ao-update.sh missing from bundled assets`,
|
||||
data: { method: "git", reason: "script_missing" },
|
||||
});
|
||||
console.error(
|
||||
chalk.red(
|
||||
"ao-update.sh is missing from the bundled assets. " +
|
||||
|
|
@ -243,6 +266,16 @@ async function handleGitUpdate(opts: {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (git) failed`,
|
||||
data: {
|
||||
method: "git",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -369,6 +402,13 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
invalidateCache();
|
||||
console.log(chalk.green("\nUpdate complete."));
|
||||
} else {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (npm/pnpm) failed: install command exited non-zero`,
|
||||
data: { method: "npm-global", command, exitCode },
|
||||
});
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { recordActivityEvent } from "@aoagents/ao-core";
|
||||
|
||||
/** Keys that AO agents commonly need and OpenClaw may already store. */
|
||||
const RESOLVABLE_KEYS = [
|
||||
|
|
@ -66,8 +67,18 @@ function readOpenClawKeys(): Record<string, string> {
|
|||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed config — silently skip.
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.credential_load_failed",
|
||||
level: "warn",
|
||||
summary: `failed to read or parse ~/.openclaw/openclaw.json`,
|
||||
data: {
|
||||
configPath,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
// Malformed config — silently skip (event surfaces it).
|
||||
}
|
||||
|
||||
return keys;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
isRepoUrl,
|
||||
loadConfig,
|
||||
parseRepoUrl,
|
||||
recordActivityEvent,
|
||||
registerProjectInGlobalConfig,
|
||||
resolveCloneTarget,
|
||||
isRepoAlreadyCloned,
|
||||
|
|
@ -196,6 +197,18 @@ async function fromUrlIntoGlobal(arg: string, deps: ResolveDeps): Promise<Resolv
|
|||
await deps.cloneRepo(parsed, targetDir, cwdDir);
|
||||
console.log(chalk.green(` Cloned to ${targetDir}`));
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_resolve_failed",
|
||||
level: "error",
|
||||
summary: `failed to clone ${parsed.ownerRepo}`,
|
||||
data: {
|
||||
ownerRepo: parsed.ownerRepo,
|
||||
targetDir,
|
||||
source: "url-global",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw new Error(
|
||||
`Failed to clone ${parsed.ownerRepo}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
|
|
@ -286,6 +299,18 @@ async function fromUrl(arg: string, deps: ResolveDeps, opts: ResolveOptions): Pr
|
|||
spinner.succeed(`Cloned to ${targetDir}`);
|
||||
} catch (err) {
|
||||
spinner.fail("Clone failed");
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_resolve_failed",
|
||||
level: "error",
|
||||
summary: `failed to clone ${parsed.ownerRepo}`,
|
||||
data: {
|
||||
ownerRepo: parsed.ownerRepo,
|
||||
targetDir,
|
||||
source: "url-local",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw new Error(
|
||||
`Failed to clone ${parsed.ownerRepo}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
|
|
@ -462,6 +487,13 @@ async function fromCwdOrId(
|
|||
// First run — auto-create config in cwd.
|
||||
config = await deps.autoCreateConfig(cwd());
|
||||
recovered = true;
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.config_recovered",
|
||||
level: "info",
|
||||
summary: `auto-created config in cwd (first-run)`,
|
||||
data: { recovery: "auto_create", cwd: cwd() },
|
||||
});
|
||||
} else {
|
||||
// A config file exists but failed to load — likely a flat local
|
||||
// config whose project isn't in the global registry yet. Recover
|
||||
|
|
@ -469,9 +501,29 @@ async function fromCwdOrId(
|
|||
const foundConfig = findConfigFile() ?? undefined;
|
||||
if (!foundConfig) throw err;
|
||||
const addedId = await deps.registerFlatConfig(foundConfig);
|
||||
if (!addedId) throw err;
|
||||
if (!addedId) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.config_recovery_failed",
|
||||
level: "error",
|
||||
summary: `registerFlatConfig returned null — recovery failed`,
|
||||
data: {
|
||||
configPath: foundConfig,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
config = loadConfig(foundConfig);
|
||||
recovered = true;
|
||||
recordActivityEvent({
|
||||
projectId: addedId,
|
||||
source: "cli",
|
||||
kind: "cli.config_recovered",
|
||||
level: "info",
|
||||
summary: `registered flat config into global config and retried load`,
|
||||
data: { recovery: "register_flat", configPath: foundConfig },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { atomicWriteFileSync } from "@aoagents/ao-core";
|
||||
import { atomicWriteFileSync, recordActivityEvent } from "@aoagents/ao-core";
|
||||
|
||||
export interface RunningState {
|
||||
pid: number;
|
||||
|
|
@ -134,6 +134,18 @@ async function acquireLock(
|
|||
}
|
||||
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.lock_timeout",
|
||||
level: "warn",
|
||||
summary: `lock acquisition timed out`,
|
||||
data: {
|
||||
resourceName,
|
||||
lockFile,
|
||||
timeoutMs,
|
||||
attempts: attempt,
|
||||
},
|
||||
});
|
||||
throw new Error(`Could not acquire ${resourceName} (${lockFile})`);
|
||||
}
|
||||
|
||||
|
|
@ -246,6 +258,18 @@ export async function getRunning(): Promise<RunningState | null> {
|
|||
if (!isProcessAlive(state.pid)) {
|
||||
// Stale entry — process is dead, clean up
|
||||
writeState(null);
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.stale_running_pruned",
|
||||
level: "warn",
|
||||
summary: `pruned stale running.json entry (dead pid ${state.pid})`,
|
||||
data: {
|
||||
pid: state.pid,
|
||||
port: state.port,
|
||||
startedAt: state.startedAt,
|
||||
projects: state.projects,
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
* see ao-118 plan PR B).
|
||||
*/
|
||||
|
||||
import { isTerminalSession, loadConfig } from "@aoagents/ao-core";
|
||||
import { isTerminalSession, loadConfig, recordActivityEvent } from "@aoagents/ao-core";
|
||||
import { stopBunTmpJanitor } from "./bun-tmp-janitor.js";
|
||||
import { getSessionManager } from "./create-session-manager.js";
|
||||
import { stopAllLifecycleWorkers } from "./lifecycle-service.js";
|
||||
|
|
@ -52,6 +52,15 @@ export function installShutdownHandlers(ctx: ShutdownContext): void {
|
|||
|
||||
const exitCode = signal === "SIGINT" ? 130 : 0;
|
||||
|
||||
recordActivityEvent({
|
||||
projectId: ctx.projectId,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_signal",
|
||||
level: "info",
|
||||
summary: `received ${signal}, beginning graceful shutdown`,
|
||||
data: { signal, exitCode },
|
||||
});
|
||||
|
||||
try {
|
||||
stopProjectSupervisor();
|
||||
stopAllLifecycleWorkers();
|
||||
|
|
@ -59,7 +68,17 @@ export function installShutdownHandlers(ctx: ShutdownContext): void {
|
|||
// Best-effort — never block shutdown on observability.
|
||||
}
|
||||
|
||||
const forceExit = setTimeout(() => process.exit(exitCode), SHUTDOWN_TIMEOUT_MS);
|
||||
const forceExit = setTimeout(() => {
|
||||
recordActivityEvent({
|
||||
projectId: ctx.projectId,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_force_exit",
|
||||
level: "warn",
|
||||
summary: `force-exit after ${SHUTDOWN_TIMEOUT_MS}ms timeout`,
|
||||
data: { signal, timeoutMs: SHUTDOWN_TIMEOUT_MS, exitCode },
|
||||
});
|
||||
process.exit(exitCode);
|
||||
}, SHUTDOWN_TIMEOUT_MS);
|
||||
forceExit.unref();
|
||||
|
||||
void (async () => {
|
||||
|
|
@ -76,8 +95,16 @@ export function installShutdownHandlers(ctx: ShutdownContext): void {
|
|||
if (result.cleaned || result.alreadyTerminated) {
|
||||
killedSessionIds.push(session.id);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort per session
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId ?? ctx.projectId,
|
||||
sessionId: session.id,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_session_kill_failed",
|
||||
level: "warn",
|
||||
summary: `failed to kill session during shutdown`,
|
||||
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,8 +133,23 @@ export function installShutdownHandlers(ctx: ShutdownContext): void {
|
|||
}
|
||||
|
||||
await unregister();
|
||||
} catch {
|
||||
// Best-effort — always exit even if cleanup fails
|
||||
recordActivityEvent({
|
||||
projectId: ctx.projectId,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_completed",
|
||||
level: "info",
|
||||
summary: `clean shutdown completed`,
|
||||
data: { signal, killedSessionCount: killedSessionIds.length, exitCode },
|
||||
});
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: ctx.projectId,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_failed",
|
||||
level: "error",
|
||||
summary: `shutdown body threw before cleanup completed`,
|
||||
data: { signal, errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
}
|
||||
try {
|
||||
// Await any in-flight sweep so shutdown does not exit while
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ export type ActivityEventSource =
|
|||
| "runtime"
|
||||
| "agent"
|
||||
| "reaction"
|
||||
| "report-watcher";
|
||||
| "report-watcher"
|
||||
| "cli";
|
||||
|
||||
export type ActivityEventKind =
|
||||
| "session.spawn_started"
|
||||
|
|
|
|||
Loading…
Reference in New Issue