mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
Merge main into project migration branch
This commit is contained in:
@@ -20,7 +20,8 @@ export type AgentLoaderManager = Pick<
|
||||
| "getRegisteredProviderIds"
|
||||
| "hydrateTimelineFromProvider"
|
||||
| "resumeAgentFromPersistence"
|
||||
>;
|
||||
> &
|
||||
Partial<Pick<AgentManager, "touchAgentActivity" | "waitForAgentClose">>;
|
||||
|
||||
export interface EnsureAgentLoadedDeps {
|
||||
agentManager: AgentLoaderManager;
|
||||
@@ -29,15 +30,45 @@ export interface EnsureAgentLoadedDeps {
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function ensureUnarchivedAgentLoaded(
|
||||
agentId: string,
|
||||
deps: EnsureAgentLoadedDeps & {
|
||||
agentManager: AgentLoaderManager & Pick<AgentManager, "closeAgent">;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
const record = await deps.agentStorage.get(agentId);
|
||||
if (record?.archivedAt) {
|
||||
throw new Error(`Agent is archived: ${agentId}`);
|
||||
}
|
||||
|
||||
const agent = await ensureAgentLoaded(agentId, deps);
|
||||
const latestRecord = await deps.agentStorage.get(agentId);
|
||||
if (latestRecord?.archivedAt) {
|
||||
await deps.agentManager.closeAgent(agentId).catch((error: unknown) => {
|
||||
deps.logger.warn({ err: error, agentId }, "Failed to close concurrently archived agent");
|
||||
});
|
||||
throw new Error(`Agent is archived: ${agentId}`);
|
||||
}
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
export async function ensureAgentLoaded(
|
||||
agentId: string,
|
||||
deps: EnsureAgentLoadedDeps,
|
||||
): Promise<ManagedAgent> {
|
||||
const existing = deps.agentManager.getAgent(agentId);
|
||||
await deps.agentManager.waitForAgentClose?.(agentId);
|
||||
const existing =
|
||||
deps.agentManager.touchAgentActivity?.(agentId) ?? deps.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// A close may have started after the first barrier observed no in-flight
|
||||
// work. Once the live lookup is empty, this second barrier closes that gap
|
||||
// before storage-backed resume begins.
|
||||
await deps.agentManager.waitForAgentClose?.(agentId);
|
||||
|
||||
const inflight = pendingAgentInitializations.get(agentId);
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
@@ -75,6 +106,7 @@ export async function ensureAgentLoaded(
|
||||
snapshot = await deps.agentManager.createAgent(config, agentId, {
|
||||
labels: record.labels,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
});
|
||||
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AgentStorage } from "./agent-storage.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { formatSystemNotificationPrompt } from "./agent-prompt.js";
|
||||
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
@@ -34,6 +35,7 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
ImportProviderSessionInput,
|
||||
ResolveAgentDefaultModeInput,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
@@ -110,11 +112,15 @@ function expectArchivedAgentRecord(
|
||||
}
|
||||
|
||||
class TestAgentClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly provider: AgentProvider;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
readonly createdConfigs: AgentSessionConfig[] = [];
|
||||
readonly resumeOverrides: Array<Partial<AgentSessionConfig> | undefined> = [];
|
||||
|
||||
constructor(provider: AgentProvider = "codex") {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
@@ -128,18 +134,18 @@ class TestAgentClient implements AgentClient {
|
||||
return {
|
||||
models: [
|
||||
{
|
||||
provider: "codex",
|
||||
provider: this.provider,
|
||||
id: "gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
provider: this.provider,
|
||||
id: "gpt-5.4-mini",
|
||||
label: "GPT-5.4 Mini",
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
provider: this.provider,
|
||||
id: "gpt-5.2-codex",
|
||||
label: "GPT-5.2 Codex",
|
||||
},
|
||||
@@ -155,7 +161,7 @@ class TestAgentClient implements AgentClient {
|
||||
): Promise<AgentSession> {
|
||||
this.resumeOverrides.push(config);
|
||||
return new TestAgentSession({
|
||||
provider: "codex",
|
||||
provider: this.provider,
|
||||
cwd: config?.cwd ?? process.cwd(),
|
||||
daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt,
|
||||
});
|
||||
@@ -934,9 +940,43 @@ test("normalizeConfig injects the provider default model when omitted", async ()
|
||||
);
|
||||
|
||||
expect(snapshot.config.model).toBe("gpt-5.4");
|
||||
expect(snapshot.config.modeId).toBe("auto-review");
|
||||
});
|
||||
|
||||
test("normalizeConfig injects Claude's automatic approval default when omitted", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-claude-default-test-"));
|
||||
const manager = new AgentManager({
|
||||
clients: { claude: new TestAgentClient("claude") },
|
||||
logger,
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({ provider: "claude", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
expect(snapshot.config.modeId).toBe("auto");
|
||||
});
|
||||
|
||||
test("normalizeConfig uses a capability-aware provider mode default", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mode-default-test-"));
|
||||
class CapabilityAwareClient extends TestAgentClient {
|
||||
override async resolveDefaultModeId(input: ResolveAgentDefaultModeInput): Promise<string> {
|
||||
return input.env?.CLAUDE_CODE_USE_BEDROCK === "1" ? "default" : "auto";
|
||||
}
|
||||
}
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new CapabilityAwareClient() },
|
||||
logger,
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
env: { CLAUDE_CODE_USE_BEDROCK: "1" },
|
||||
});
|
||||
|
||||
expect(snapshot.config.modeId).toBe("default");
|
||||
});
|
||||
|
||||
test("createAgent forwards request env into the spawned provider process", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-env-test-"));
|
||||
const client = new EnvProbeAgentClient();
|
||||
@@ -996,7 +1036,7 @@ test("normalizeConfig strips legacy 'default' model id", async () => {
|
||||
);
|
||||
|
||||
expect(snapshot.config.model).toBe("gpt-5.4");
|
||||
expect(snapshot.config.modeId).toBe("auto");
|
||||
expect(snapshot.config.modeId).toBe("auto-review");
|
||||
});
|
||||
|
||||
test("listDraftCommands returns no commands without guessing a missing model", async () => {
|
||||
@@ -1093,7 +1133,7 @@ test("listDraftCommands uses explicit model config without default model fetchin
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
model: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
modeId: "auto-review",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1193,7 +1233,7 @@ test("listDraftFeatures uses explicit model config without default model fetchin
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
model: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
modeId: "auto-review",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1650,7 +1690,7 @@ test("createAgent passes daemon launch env through the provider launch context",
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
model: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
modeId: "auto-review",
|
||||
});
|
||||
expect(client.lastLaunchContext).toEqual({
|
||||
agentId: snapshot.id,
|
||||
@@ -2447,7 +2487,7 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
|
||||
});
|
||||
expect(client.lastResumeOverrides).toMatchObject({
|
||||
model: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
modeId: "auto-review",
|
||||
systemPrompt: "new prompt",
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
@@ -7159,6 +7199,464 @@ test("closeAgent persists one final closed snapshot", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("collectIdleAgents releases an idle runtime and resumes the same agent and timeline", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-collection-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
let activeSession: TestAgentSession | null = null;
|
||||
const client = new (class extends NativeArchiveRecordingClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
activeSession = new TestAgentSession(config);
|
||||
return activeSession;
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000210",
|
||||
});
|
||||
|
||||
try {
|
||||
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: "workspace-idle-collection",
|
||||
});
|
||||
await manager.appendTimelineItem(created.id, {
|
||||
type: "user_message",
|
||||
text: "Keep this timeline",
|
||||
});
|
||||
activeSession?.pushEvent({
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: "retained-provider-child",
|
||||
title: "Retained provider child",
|
||||
status: "completed",
|
||||
},
|
||||
});
|
||||
await manager.flush();
|
||||
const timelineBeforeCollection = manager.getTimeline(created.id);
|
||||
|
||||
const collection = await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
|
||||
expect(collection).toEqual({
|
||||
collected: [
|
||||
{
|
||||
agentId: created.id,
|
||||
provider: "codex",
|
||||
sessionId: created.persistence?.sessionId,
|
||||
},
|
||||
],
|
||||
failures: [],
|
||||
});
|
||||
expect(manager.getAgent(created.id)).toBeNull();
|
||||
expect(client.archivedHandles).toEqual([]);
|
||||
const stored = await storage.get(created.id);
|
||||
expect(stored).toMatchObject({
|
||||
id: created.id,
|
||||
lastStatus: "closed",
|
||||
workspaceId: "workspace-idle-collection",
|
||||
});
|
||||
expect(stored?.archivedAt).toBeFalsy();
|
||||
|
||||
const resumed = await ensureAgentLoaded(created.id, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(resumed.id).toBe(created.id);
|
||||
expect(resumed.persistence).toEqual(created.persistence);
|
||||
expect(manager.getTimeline(created.id)).toEqual(timelineBeforeCollection);
|
||||
expect(manager.listProviderSubagents(created.id)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "retained-provider-child",
|
||||
title: "Retained provider child",
|
||||
status: "completed",
|
||||
}),
|
||||
]);
|
||||
const idleBeforeOpen = resumed.updatedAt;
|
||||
await ensureAgentLoaded(created.id, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
await expect(
|
||||
manager.collectIdleAgents({ cutoff: idleBeforeOpen, protectedAgentIds: new Set() }),
|
||||
).resolves.toMatchObject({ collected: [] });
|
||||
await expect(manager.runAgent(created.id, "Continue the same agent")).resolves.toMatchObject({
|
||||
finalText: "",
|
||||
canceled: false,
|
||||
});
|
||||
expect(manager.getAgent(created.id)?.id).toBe(created.id);
|
||||
} finally {
|
||||
await manager.flush().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("archiving an idle-collected parent still cascades to its managed children", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-collected-parent-archive-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Collected parent" },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Managed child" },
|
||||
undefined,
|
||||
{
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: parent.id },
|
||||
workspaceId: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set([child.id]),
|
||||
});
|
||||
await manager.archiveSnapshot(parent.id, new Date().toISOString());
|
||||
|
||||
expect((await storage.get(parent.id))?.archivedAt).toEqual(expect.any(String));
|
||||
expect((await storage.get(child.id))?.archivedAt).toEqual(expect.any(String));
|
||||
expect(manager.getAgent(child.id)).toBeNull();
|
||||
} finally {
|
||||
await manager.flush().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-load-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
await manager.archiveSnapshot(agent.id, new Date().toISOString());
|
||||
|
||||
await expect(
|
||||
ensureUnarchivedAgentLoaded(agent.id, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
}),
|
||||
).rejects.toThrow(`Agent is archived: ${agent.id}`);
|
||||
expect(manager.getAgent(agent.id)).toBeNull();
|
||||
} finally {
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-resume-race-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const resumeStarted = deferred<void>();
|
||||
const resumeAllowed = deferred<void>();
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
resumeStarted.resolve();
|
||||
await resumeAllowed.promise;
|
||||
return super.resumeSession(handle, config, launchContext);
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
|
||||
const load = ensureUnarchivedAgentLoaded(agent.id, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
await resumeStarted.promise;
|
||||
await manager.archiveSnapshot(agent.id, new Date().toISOString());
|
||||
resumeAllowed.resolve();
|
||||
|
||||
await expect(load).rejects.toThrow(`Agent is archived: ${agent.id}`);
|
||||
expect(manager.getAgent(agent.id)).toBeNull();
|
||||
expect((await storage.get(agent.id))?.archivedAt).toEqual(expect.any(String));
|
||||
} finally {
|
||||
resumeAllowed.resolve();
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("ensureUnarchivedAgentLoaded fences an archived agent after joining a shared resume", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-shared-resume-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const resumeStarted = deferred<void>();
|
||||
const resumeAllowed = deferred<void>();
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
resumeStarted.resolve();
|
||||
await resumeAllowed.promise;
|
||||
return super.resumeSession(handle, config, launchContext);
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
|
||||
const sharedLoad = ensureAgentLoaded(agent.id, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
await resumeStarted.promise;
|
||||
const protectedLoad = ensureUnarchivedAgentLoaded(agent.id, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
await manager.archiveSnapshot(agent.id, new Date().toISOString());
|
||||
resumeAllowed.resolve();
|
||||
|
||||
await sharedLoad;
|
||||
await expect(protectedLoad).rejects.toThrow(`Agent is archived: ${agent.id}`);
|
||||
expect(manager.getAgent(agent.id)).toBeNull();
|
||||
expect((await storage.get(agent.id))?.archivedAt).toEqual(expect.any(String));
|
||||
} finally {
|
||||
resumeAllowed.resolve();
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("collectIdleAgents leaves recent, protected, internal, running, and error agents resident", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-eligibility-"));
|
||||
const client = new (class extends TestAgentClient {
|
||||
readonly sessions: TestAgentSession[] = [];
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const session = new TestAgentSession(config);
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
})();
|
||||
const ids = [
|
||||
"00000000-0000-4000-8000-000000000211",
|
||||
"00000000-0000-4000-8000-000000000212",
|
||||
"00000000-0000-4000-8000-000000000213",
|
||||
"00000000-0000-4000-8000-000000000214",
|
||||
"00000000-0000-4000-8000-000000000215",
|
||||
];
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
logger,
|
||||
idFactory: () => ids.shift()!,
|
||||
});
|
||||
|
||||
try {
|
||||
const recent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const protectedAgent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const internal = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, internal: true },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const running = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const failed = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
client.sessions[3]!.pushEvent({
|
||||
type: "turn_started",
|
||||
provider: "codex",
|
||||
turnId: "autonomous-running",
|
||||
});
|
||||
client.sessions[4]!.pushEvent({
|
||||
type: "turn_failed",
|
||||
provider: "codex",
|
||||
turnId: "autonomous-failed",
|
||||
error: "provider failed",
|
||||
});
|
||||
await manager.flush();
|
||||
|
||||
const recentSweep = await manager.collectIdleAgents({
|
||||
cutoff: new Date(recent.updatedAt.getTime() - 1),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
const protectedSweep = await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set([protectedAgent.id, recent.id]),
|
||||
});
|
||||
|
||||
expect(recentSweep.collected).toEqual([]);
|
||||
expect(protectedSweep.collected).toEqual([]);
|
||||
expect(manager.getAgent(recent.id)?.lifecycle).toBe("idle");
|
||||
expect(manager.getAgent(protectedAgent.id)?.lifecycle).toBe("idle");
|
||||
expect(manager.getAgent(internal.id)?.lifecycle).toBe("idle");
|
||||
expect(manager.getAgent(running.id)?.lifecycle).toBe("running");
|
||||
expect(manager.getAgent(failed.id)?.lifecycle).toBe("error");
|
||||
} finally {
|
||||
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
|
||||
() => undefined,
|
||||
);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("load waits for an in-flight collection close and creates only one resumed runtime", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-close-race-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const closeStarted = deferred<void>();
|
||||
const closeAllowed = deferred<void>();
|
||||
const client = new (class extends TestAgentClient {
|
||||
resumeCount = 0;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new (class extends TestAgentSession {
|
||||
override async close(): Promise<void> {
|
||||
closeStarted.resolve();
|
||||
await closeAllowed.promise;
|
||||
}
|
||||
})(config);
|
||||
}
|
||||
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
this.resumeCount += 1;
|
||||
return super.resumeSession(handle, config);
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
|
||||
try {
|
||||
const created = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir },
|
||||
"00000000-0000-4000-8000-000000000216",
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const collection = manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
await closeStarted.promise;
|
||||
const loads = Promise.all([
|
||||
ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }),
|
||||
ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }),
|
||||
]);
|
||||
|
||||
expect(client.resumeCount).toBe(0);
|
||||
closeAllowed.resolve();
|
||||
const [first, second] = await loads;
|
||||
await collection;
|
||||
|
||||
expect(first.id).toBe(created.id);
|
||||
expect(second.id).toBe(created.id);
|
||||
expect(client.resumeCount).toBe(1);
|
||||
} finally {
|
||||
await manager.closeAgent("00000000-0000-4000-8000-000000000216").catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("provider close failure still persists and emits a resumable closed agent", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new (class extends TestAgentSession {
|
||||
override async close(): Promise<void> {
|
||||
throw new Error("provider cleanup failed");
|
||||
}
|
||||
})(config);
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
|
||||
try {
|
||||
const created = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir },
|
||||
"00000000-0000-4000-8000-000000000217",
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const closed = waitForAgentLifecycle(manager, created.id, "closed");
|
||||
|
||||
const collection = await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
await closed;
|
||||
expect(collection.collected).toEqual([]);
|
||||
expect(collection.failures).toHaveLength(1);
|
||||
expect(collection.failures[0]).toMatchObject({
|
||||
agentId: created.id,
|
||||
provider: "codex",
|
||||
error: expect.objectContaining({ message: "provider cleanup failed" }),
|
||||
});
|
||||
const stored = await storage.get(created.id);
|
||||
expect(stored).toMatchObject({ lastStatus: "closed" });
|
||||
expect(stored?.archivedAt).toBeFalsy();
|
||||
|
||||
await expect(
|
||||
ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }),
|
||||
).resolves.toMatchObject({ id: created.id, lifecycle: "idle" });
|
||||
} finally {
|
||||
await manager.closeAgent("00000000-0000-4000-8000-000000000217").catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("hydrateTimeline keeps provider user_message items when no canonical user history exists", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-keep-user-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "./agent-sdk-types.js";
|
||||
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
||||
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentOwner } from "./agent-owner.js";
|
||||
import {
|
||||
InMemoryAgentTimelineStore,
|
||||
type SeedAgentTimelineOptions,
|
||||
@@ -116,6 +117,7 @@ interface PreparedSessionConfig {
|
||||
|
||||
interface NormalizeConfigOptions {
|
||||
resolveDefaultModel?: boolean;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface TimeoutOptions {
|
||||
@@ -232,6 +234,7 @@ export interface CreateAgentOptions {
|
||||
initialTitle?: string | null;
|
||||
// undefined is an explicit decision: the agent never appears in the sidebar.
|
||||
workspaceId: string | undefined;
|
||||
owner?: AgentOwner;
|
||||
}
|
||||
|
||||
export interface AgentManagerOptions {
|
||||
@@ -306,6 +309,7 @@ interface ManagedAgentBase {
|
||||
* Null/undefined for legacy agents created before ownership stamping.
|
||||
*/
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
config: AgentSessionConfig;
|
||||
runtimeInfo?: AgentRuntimeInfo;
|
||||
@@ -388,6 +392,21 @@ export interface AgentMetricsSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
export interface IdleAgentCollectionEntry {
|
||||
agentId: string;
|
||||
provider: AgentProvider;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface IdleAgentCollectionFailure extends IdleAgentCollectionEntry {
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export interface IdleAgentCollectionResult {
|
||||
collected: IdleAgentCollectionEntry[];
|
||||
failures: IdleAgentCollectionFailure[];
|
||||
}
|
||||
|
||||
type ActiveManagedAgent =
|
||||
| ManagedAgentInitializing
|
||||
| ManagedAgentIdle
|
||||
@@ -565,6 +584,7 @@ export class AgentManager {
|
||||
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private readonly agentRegistrationTasks = new Set<Promise<void>>();
|
||||
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
|
||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||
private mcpBaseUrl: string | null;
|
||||
private readonly mcpAuthToken: string | null;
|
||||
@@ -780,6 +800,10 @@ export class AgentManager {
|
||||
};
|
||||
}
|
||||
|
||||
subscriptionCount(): number {
|
||||
return this.subscribers.size;
|
||||
}
|
||||
|
||||
listAgents(): ManagedAgent[] {
|
||||
return Array.from(this.agents.values())
|
||||
.filter((agent) => !agent.internal)
|
||||
@@ -942,6 +966,19 @@ export class AgentManager {
|
||||
return agent ? { ...agent } : null;
|
||||
}
|
||||
|
||||
touchAgentActivity(id: string): ManagedAgent | null {
|
||||
const agent = this.agents?.get(id);
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
return { ...agent };
|
||||
}
|
||||
|
||||
async waitForAgentClose(agentId: string): Promise<void> {
|
||||
await this.inFlightAgentCloses?.get(agentId)?.catch(() => undefined);
|
||||
}
|
||||
|
||||
getTimeline(id: string): AgentTimelineItem[] {
|
||||
this.requireAgent(id);
|
||||
return this.timelineStore.getItems(id);
|
||||
@@ -997,7 +1034,12 @@ export class AgentManager {
|
||||
): Promise<ManagedAgent> {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||
const { storedConfig, launchConfig } = await this.prepareSessionConfig(config, resolvedAgentId);
|
||||
await this.deleteAgentState(resolvedAgentId);
|
||||
const { storedConfig, launchConfig } = await this.prepareSessionConfig(
|
||||
config,
|
||||
resolvedAgentId,
|
||||
options?.env,
|
||||
);
|
||||
this.requireEnabledProvider(storedConfig.provider);
|
||||
const client = await this.requireAvailableClient({
|
||||
provider: storedConfig.provider,
|
||||
@@ -1010,6 +1052,7 @@ export class AgentManager {
|
||||
labels: options.labels,
|
||||
initialTitle: options.initialTitle,
|
||||
workspaceId: options.workspaceId,
|
||||
owner: options.owner,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1033,6 +1076,7 @@ export class AgentManager {
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
return this.trackAgentRegistrationOperation(
|
||||
@@ -1050,6 +1094,7 @@ export class AgentManager {
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
@@ -1078,7 +1123,10 @@ export class AgentManager {
|
||||
const launchContext = await this.buildLaunchContext(resolvedAgentId, client);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
const session = await client.resumeSession(handle, providerLaunchConfig, launchContext);
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, options);
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
...options,
|
||||
persistence: handle,
|
||||
});
|
||||
}
|
||||
|
||||
importProviderSession(input: {
|
||||
@@ -1229,6 +1277,7 @@ export class AgentManager {
|
||||
return this.registerSession(session, storedConfig, agentId, {
|
||||
labels: existing.labels,
|
||||
workspaceId: existing.workspaceId,
|
||||
owner: existing.owner,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
lastUserMessageAt: existing.lastUserMessageAt,
|
||||
@@ -1298,7 +1347,24 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
async closeAgent(agentId: string): Promise<void> {
|
||||
closeAgent(agentId: string): Promise<void> {
|
||||
const existing = this.inFlightAgentCloses.get(agentId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const close = this.closeAgentRuntime(agentId);
|
||||
this.inFlightAgentCloses.set(agentId, close);
|
||||
const clearClose = () => {
|
||||
if (this.inFlightAgentCloses.get(agentId) === close) {
|
||||
this.inFlightAgentCloses.delete(agentId);
|
||||
}
|
||||
};
|
||||
void close.then(clearClose, clearClose);
|
||||
return close;
|
||||
}
|
||||
|
||||
private async closeAgentRuntime(agentId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -1313,12 +1379,19 @@ export class AgentManager {
|
||||
"agent.manager.close.start",
|
||||
);
|
||||
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
|
||||
await agent.session.close();
|
||||
this.timelineStore.delete(agentId);
|
||||
for (const event of this.providerSubagents.deleteParent(agentId)) {
|
||||
this.dispatch({ type: "provider_subagent", event });
|
||||
let closeError: unknown;
|
||||
try {
|
||||
await agent.session.close();
|
||||
} catch (error) {
|
||||
closeError = error;
|
||||
}
|
||||
|
||||
let persistError: unknown;
|
||||
try {
|
||||
await this.persistSnapshot(closedAgent);
|
||||
} catch (error) {
|
||||
persistError = error;
|
||||
}
|
||||
await this.persistSnapshot(closedAgent);
|
||||
this.emitClosedAgent(closedAgent, { persist: false });
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -1328,6 +1401,58 @@ export class AgentManager {
|
||||
},
|
||||
"agent.manager.close.complete",
|
||||
);
|
||||
|
||||
if (closeError !== undefined) {
|
||||
throw closeError;
|
||||
}
|
||||
if (persistError !== undefined) {
|
||||
throw persistError;
|
||||
}
|
||||
}
|
||||
|
||||
async collectIdleAgents(options: {
|
||||
cutoff: Date;
|
||||
protectedAgentIds: ReadonlySet<string>;
|
||||
}): Promise<IdleAgentCollectionResult> {
|
||||
const result: IdleAgentCollectionResult = { collected: [], failures: [] };
|
||||
|
||||
for (const agent of Array.from(this.agents.values())) {
|
||||
const current = this.agents.get(agent.id);
|
||||
if (!current || !this.isIdleAgentCollectable(current, options)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry: IdleAgentCollectionEntry = {
|
||||
agentId: current.id,
|
||||
provider: current.provider,
|
||||
...(current.persistence?.sessionId ? { sessionId: current.persistence.sessionId } : {}),
|
||||
};
|
||||
try {
|
||||
await this.closeAgent(current.id);
|
||||
result.collected.push(entry);
|
||||
} catch (error) {
|
||||
result.failures.push({ ...entry, error });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private isIdleAgentCollectable(
|
||||
agent: LiveManagedAgent,
|
||||
options: { cutoff: Date; protectedAgentIds: ReadonlySet<string> },
|
||||
): agent is ManagedAgentIdle {
|
||||
return (
|
||||
agent.lifecycle === "idle" &&
|
||||
agent.updatedAt.getTime() <= options.cutoff.getTime() &&
|
||||
!agent.internal &&
|
||||
!options.protectedAgentIds.has(agent.id) &&
|
||||
agent.activeForegroundTurnId === null &&
|
||||
!this.runs.hasRun(agent.id) &&
|
||||
!agent.pendingReplacement &&
|
||||
agent.pendingPermissions.size === 0 &&
|
||||
agent.inFlightPermissionResponses.size === 0
|
||||
);
|
||||
}
|
||||
|
||||
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
|
||||
@@ -1347,6 +1472,7 @@ export class AgentManager {
|
||||
const { archivedAt } = await this.markRecordArchived(stored);
|
||||
agent.updatedAt = new Date(archivedAt);
|
||||
await this.closeAgent(agentId);
|
||||
this.discardRetainedAgentState(agentId);
|
||||
|
||||
await this.cascadeArchiveChildren(agentId);
|
||||
|
||||
@@ -1373,8 +1499,7 @@ export class AgentManager {
|
||||
if (this.agents.has(record.id)) {
|
||||
await this.archiveAgent(record.id);
|
||||
} else {
|
||||
await this.markRecordArchived(record);
|
||||
await this.cascadeArchiveChildren(record.id);
|
||||
await this.archiveSnapshot(record.id, new Date().toISOString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1420,6 +1545,7 @@ export class AgentManager {
|
||||
provider: record.provider,
|
||||
cwd: record.cwd,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
session: null,
|
||||
capabilities: STORED_AGENT_CAPABILITIES,
|
||||
config: buildStoredAgentConfig(record),
|
||||
@@ -1660,11 +1786,15 @@ export class AgentManager {
|
||||
|
||||
if (this.agents.has(agentId)) {
|
||||
this.notifyAgentState(agentId);
|
||||
} else if (!nextRecord.internal) {
|
||||
this.dispatchArchivedStoredAgent(nextRecord);
|
||||
} else {
|
||||
this.discardRetainedAgentState(agentId);
|
||||
if (!nextRecord.internal) {
|
||||
this.dispatchArchivedStoredAgent(nextRecord);
|
||||
}
|
||||
}
|
||||
|
||||
await this.fireAgentArchived(agentId);
|
||||
await this.cascadeArchiveChildren(agentId);
|
||||
|
||||
return nextRecord;
|
||||
}
|
||||
@@ -2315,6 +2445,11 @@ export class AgentManager {
|
||||
await this.durableTimelineStore.deleteAgent(agentId);
|
||||
}
|
||||
|
||||
async deleteAgentState(agentId: string): Promise<void> {
|
||||
this.discardRetainedAgentState(agentId);
|
||||
await this.deleteCommittedTimeline(agentId);
|
||||
}
|
||||
|
||||
async getLastAssistantMessage(agentId: string): Promise<string | null> {
|
||||
const agent = this.agents.get(agentId);
|
||||
if (!agent) {
|
||||
@@ -2574,6 +2709,7 @@ export class AgentManager {
|
||||
initialTitle?: string | null;
|
||||
publishWhenReady?: boolean;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
let registered = false;
|
||||
@@ -2623,6 +2759,7 @@ export class AgentManager {
|
||||
await this.refreshSessionState(managed, { emit: false });
|
||||
this.assertAgentRegistrationActive(managed);
|
||||
managed.lifecycle = "idle";
|
||||
this.touchUpdatedAt(managed);
|
||||
await this.persistSnapshot(managed);
|
||||
this.assertAgentRegistrationActive(managed);
|
||||
this.emitState(managed, { persist: false });
|
||||
@@ -2671,6 +2808,7 @@ export class AgentManager {
|
||||
| undefined;
|
||||
}): Promise<{ durableTimelineHasRows: boolean }> {
|
||||
const { agentId, now, options } = params;
|
||||
const timelineAlreadyPrimed = this.timelineStore.has(agentId);
|
||||
const explicitTimelineSeed = buildExplicitTimelineSeedForRegister(now, options);
|
||||
const shouldSeedFromDurable =
|
||||
!explicitTimelineSeed &&
|
||||
@@ -2680,7 +2818,8 @@ export class AgentManager {
|
||||
? await this.loadCommittedTimelineSeed(agentId, now)
|
||||
: null;
|
||||
const durableTimelineHasRows =
|
||||
durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1;
|
||||
timelineAlreadyPrimed ||
|
||||
(durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1);
|
||||
const timelineSeed = explicitTimelineSeed ?? durableTimelineSeed;
|
||||
if (timelineSeed || !this.timelineStore.has(agentId)) {
|
||||
this.timelineStore.initialize(agentId, timelineSeed ?? { timestamp: now.toISOString() });
|
||||
@@ -2709,6 +2848,7 @@ export class AgentManager {
|
||||
attention?: AttentionState;
|
||||
persistence?: AgentPersistenceHandle;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
}
|
||||
| undefined;
|
||||
}): ActiveManagedAgent {
|
||||
@@ -2718,6 +2858,7 @@ export class AgentManager {
|
||||
provider: config.provider,
|
||||
cwd: config.cwd,
|
||||
workspaceId: options?.workspaceId,
|
||||
owner: options?.owner,
|
||||
session,
|
||||
capabilities: session.capabilities,
|
||||
config,
|
||||
@@ -2786,9 +2927,23 @@ export class AgentManager {
|
||||
lifecycle: "closed",
|
||||
session: null,
|
||||
activeForegroundTurnId: null,
|
||||
pendingPermissions: new Map(),
|
||||
bufferedPermissionResolutions: new Map(),
|
||||
inFlightPermissionResponses: new Set(),
|
||||
pendingReplacement: false,
|
||||
foregroundTurnWaiters: new Set(),
|
||||
finalizedForegroundTurnIds: new Set(),
|
||||
unsubscribeSession: null,
|
||||
};
|
||||
}
|
||||
|
||||
private discardRetainedAgentState(agentId: string): void {
|
||||
this.timelineStore.delete(agentId);
|
||||
for (const event of this.providerSubagents.deleteParent(agentId)) {
|
||||
this.dispatch({ type: "provider_subagent", event });
|
||||
}
|
||||
}
|
||||
|
||||
private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void {
|
||||
this.emitState(agent, options);
|
||||
}
|
||||
@@ -3925,17 +4080,27 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
if (!normalized.modeId) {
|
||||
try {
|
||||
normalized.modeId =
|
||||
getAgentProviderDefinition(normalized.provider).defaultModeId ?? undefined;
|
||||
} catch {
|
||||
// Unknown provider
|
||||
}
|
||||
normalized.modeId = await this.resolveDefaultModeId(normalized, options.env);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private async resolveDefaultModeId(
|
||||
config: AgentSessionConfig,
|
||||
env?: Record<string, string>,
|
||||
): Promise<string | undefined> {
|
||||
const providerDefault = await this.clients
|
||||
.get(config.provider)
|
||||
?.resolveDefaultModeId?.({ config, env });
|
||||
if (providerDefault) return providerDefault;
|
||||
try {
|
||||
return getAgentProviderDefinition(config.provider).defaultModeId ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveDefaultModelId(config: AgentSessionConfig): Promise<string | undefined> {
|
||||
const client = this.clients.get(config.provider);
|
||||
if (!client) {
|
||||
@@ -3957,8 +4122,9 @@ export class AgentManager {
|
||||
private async prepareSessionConfig(
|
||||
config: AgentSessionConfig,
|
||||
agentId: string,
|
||||
env?: Record<string, string>,
|
||||
): Promise<PreparedSessionConfig> {
|
||||
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config));
|
||||
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env });
|
||||
const launchConfig = this.applyDaemonAppendSystemPrompt(
|
||||
withRuntimePaseoMcpServer({
|
||||
config: storedConfig,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const AgentOwnerSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("daemon"),
|
||||
daemonId: z.string(),
|
||||
executionId: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type AgentOwner = z.infer<typeof AgentOwnerSchema>;
|
||||
export type DaemonAgentOwner = Extract<AgentOwner, { kind: "daemon" }>;
|
||||
|
||||
export function daemonExecutionKey(owner: DaemonAgentOwner): string {
|
||||
return `${owner.daemonId}\0${owner.executionId}`;
|
||||
}
|
||||
@@ -93,6 +93,7 @@ export function toStoredAgentRecord(
|
||||
? agent.attention.attentionTimestamp.toISOString()
|
||||
: null,
|
||||
internal: options?.internal,
|
||||
owner: agent.owner,
|
||||
} satisfies StoredAgentRecord;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,13 +38,17 @@ function createCapturedLogger(): CapturedLogger {
|
||||
|
||||
interface FinishNotificationScenarioOptions {
|
||||
childLastAssistantMessage?: string | null;
|
||||
childParentAgentId?: string | null;
|
||||
requireParentOwnership?: boolean;
|
||||
parentPromptError?: Error;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
interface FinishNotificationScenario {
|
||||
startWatchingChild(): void;
|
||||
finishChild(): void;
|
||||
finishChildAndReadParentPrompt(): Promise<string>;
|
||||
wasParentPrompted(): boolean;
|
||||
}
|
||||
|
||||
function createFinishNotificationScenario(
|
||||
@@ -52,6 +56,7 @@ function createFinishNotificationScenario(
|
||||
): FinishNotificationScenario {
|
||||
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
|
||||
let resolveParentPrompt: ((prompt: string) => void) | null = null;
|
||||
let parentPrompted = false;
|
||||
|
||||
const childAgent: ManagedAgent = Object.create(null);
|
||||
Reflect.set(childAgent, "id", "child-agent");
|
||||
@@ -85,6 +90,7 @@ function createFinishNotificationScenario(
|
||||
Reflect.set(agentManager, "tryRunOutOfBand", () => false);
|
||||
Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError));
|
||||
Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => {
|
||||
parentPrompted = true;
|
||||
resolveParentPrompt?.(prompt);
|
||||
return (async function* noop() {})();
|
||||
});
|
||||
@@ -96,7 +102,12 @@ function createFinishNotificationScenario(
|
||||
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
|
||||
Reflect.set(agentStorage, "get", async (agentId: string) => {
|
||||
if (agentId === "child-agent") {
|
||||
return { title: "Child Agent" };
|
||||
const parentAgentId =
|
||||
options?.childParentAgentId === undefined ? "caller-agent" : options.childParentAgentId;
|
||||
return {
|
||||
title: "Child Agent",
|
||||
labels: parentAgentId ? { "paseo.parent-agent-id": parentAgentId } : {},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -108,14 +119,11 @@ function createFinishNotificationScenario(
|
||||
agentStorage,
|
||||
childAgentId: "child-agent",
|
||||
callerAgentId: "caller-agent",
|
||||
requireParentOwnership: options?.requireParentOwnership,
|
||||
logger: options?.logger ?? createTestLogger(),
|
||||
});
|
||||
},
|
||||
async finishChildAndReadParentPrompt() {
|
||||
const parentPrompt = new Promise<string>((resolve) => {
|
||||
resolveParentPrompt = resolve;
|
||||
});
|
||||
|
||||
finishChild() {
|
||||
childAgent.lifecycle = "running";
|
||||
subscriber?.({
|
||||
type: "agent_state",
|
||||
@@ -127,9 +135,18 @@ function createFinishNotificationScenario(
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
},
|
||||
async finishChildAndReadParentPrompt() {
|
||||
const parentPrompt = new Promise<string>((resolve) => {
|
||||
resolveParentPrompt = resolve;
|
||||
});
|
||||
this.finishChild();
|
||||
|
||||
return parentPrompt;
|
||||
},
|
||||
wasParentPrompted() {
|
||||
return parentPrompted;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,6 +209,26 @@ test("finish notifications tell the parent the child's last assistant message",
|
||||
);
|
||||
});
|
||||
|
||||
test("detaching a child ends its parent-owned finish notification", async () => {
|
||||
const scenario = createFinishNotificationScenario({
|
||||
childParentAgentId: null,
|
||||
requireParentOwnership: true,
|
||||
});
|
||||
scenario.startWatchingChild();
|
||||
scenario.finishChild();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(scenario.wasParentPrompted()).toBe(false);
|
||||
});
|
||||
|
||||
test("follow-up finish notifications do not require a parent relationship", async () => {
|
||||
const scenario = createFinishNotificationScenario({ childParentAgentId: "another-agent" });
|
||||
|
||||
scenario.startWatchingChild();
|
||||
const parentPrompt = await scenario.finishChildAndReadParentPrompt();
|
||||
|
||||
expect(parentPrompt).toContain("Agent child-agent (Child Agent) finished.");
|
||||
});
|
||||
|
||||
test("finish notifications log a rejected parent prompt without an unhandled rejection", async () => {
|
||||
const captured = createCapturedLogger();
|
||||
const scenario = createFinishNotificationScenario({
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
|
||||
export type AgentUnarchiveController = Pick<AgentManager, "notifyAgentState" | "unarchiveSnapshot">;
|
||||
|
||||
@@ -244,6 +245,7 @@ export interface SetupFinishNotificationParams {
|
||||
agentStorage: AgentStorage;
|
||||
childAgentId: string;
|
||||
callerAgentId: string;
|
||||
requireParentOwnership?: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
@@ -264,7 +266,14 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri
|
||||
}
|
||||
|
||||
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
|
||||
const { agentManager, agentStorage, childAgentId, callerAgentId, logger } = params;
|
||||
const {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId,
|
||||
callerAgentId,
|
||||
requireParentOwnership = false,
|
||||
logger,
|
||||
} = params;
|
||||
let hasSeenRunning = false;
|
||||
let fired = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
@@ -282,6 +291,9 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
}
|
||||
|
||||
const record = await agentStorage.get(childAgentId);
|
||||
if (requireParentOwnership && getParentAgentIdFromLabels(record?.labels) !== callerAgentId) {
|
||||
return;
|
||||
}
|
||||
const title = record?.title ?? childAgentId;
|
||||
const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId);
|
||||
const body = formatFinishNotificationBody({
|
||||
|
||||
@@ -382,6 +382,8 @@ export async function generateStructuredAgentResponse<T>(
|
||||
await manager.closeAgent(agent.id);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
} finally {
|
||||
await manager.deleteAgentState(agent.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,6 +630,7 @@ export interface AgentSession {
|
||||
): Promise<AgentPermissionResult | void>;
|
||||
describePersistence(): AgentPersistenceHandle | null;
|
||||
interrupt(): Promise<void>;
|
||||
/** Release live runtime resources without archiving or deleting the durable native session. */
|
||||
close(): Promise<void>;
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
@@ -667,6 +668,12 @@ export type FetchCatalogOptions =
|
||||
export interface ProviderCatalog {
|
||||
models: AgentModelDefinition[];
|
||||
modes: AgentMode[];
|
||||
defaultModeId?: string | null;
|
||||
}
|
||||
|
||||
export interface ResolveAgentDefaultModeInput {
|
||||
config: AgentSessionConfig;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AgentClient {
|
||||
@@ -689,6 +696,7 @@ export interface AgentClient {
|
||||
* The registry is responsible for merging configured model overrides.
|
||||
*/
|
||||
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
|
||||
resolveDefaultModeId?(input: ResolveAgentDefaultModeInput): Promise<string | undefined>;
|
||||
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
|
||||
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
|
||||
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
|
||||
@@ -707,12 +715,12 @@ export interface AgentClient {
|
||||
isAvailable(): Promise<boolean>;
|
||||
getDiagnostic?(): Promise<{ diagnostic: string }>;
|
||||
/**
|
||||
* Archive a persisted session in the native provider (best-effort).
|
||||
* Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close().
|
||||
* Called when Paseo archives an agent so the provider's own UI reflects the same state.
|
||||
*/
|
||||
archiveNativeSession?(handle: AgentPersistenceHandle): Promise<void>;
|
||||
/**
|
||||
* Unarchive a persisted session in the native provider.
|
||||
* Unarchive a durable native session in the provider.
|
||||
* Called before Paseo clears its archived flag so provider resume can succeed.
|
||||
*/
|
||||
unarchiveNativeSession?(handle: AgentPersistenceHandle): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { promises as fs, type Dirent } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
@@ -8,6 +8,7 @@ import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.js";
|
||||
|
||||
const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||
.object({
|
||||
@@ -64,6 +65,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
attentionTimestamp: z.string().nullable().optional(),
|
||||
internal: z.boolean().optional(),
|
||||
archivedAt: z.string().nullable().optional(),
|
||||
owner: AgentOwnerSchema.optional(),
|
||||
});
|
||||
|
||||
export type SerializableAgentConfig = Pick<
|
||||
@@ -88,6 +90,8 @@ export class AgentStorage {
|
||||
private pathsById: Map<string, Set<string>> = new Map();
|
||||
private pendingWrites: Map<string, Promise<void>> = new Map();
|
||||
private deleting: Set<string> = new Set();
|
||||
private daemonAgentIdsByExecution: Map<string, string> = new Map();
|
||||
private daemonExecutionKeysByAgentId: Map<string, string> = new Map();
|
||||
private loaded = false;
|
||||
private baseDir: string;
|
||||
private loadPromise: Promise<StoredAgentRecord[]> | null = null;
|
||||
@@ -112,6 +116,12 @@ export class AgentStorage {
|
||||
return this.cache.get(agentId) ?? null;
|
||||
}
|
||||
|
||||
async findByDaemonExecution(owner: DaemonAgentOwner): Promise<StoredAgentRecord | null> {
|
||||
await this.load();
|
||||
const agentId = this.daemonAgentIdsByExecution.get(daemonExecutionKey(owner));
|
||||
return agentId ? (this.cache.get(agentId) ?? null) : null;
|
||||
}
|
||||
|
||||
async upsert(record: StoredAgentRecord): Promise<void> {
|
||||
await this.load();
|
||||
await this.queueRecordWrite(record);
|
||||
@@ -157,6 +167,7 @@ export class AgentStorage {
|
||||
}
|
||||
|
||||
this.cache.set(agentId, record);
|
||||
this.indexOwner(record);
|
||||
this.pathById.set(agentId, nextPath);
|
||||
}
|
||||
|
||||
@@ -186,6 +197,7 @@ export class AgentStorage {
|
||||
);
|
||||
|
||||
this.cache.delete(agentId);
|
||||
this.removeOwnerIndex(agentId);
|
||||
this.pathById.delete(agentId);
|
||||
this.pathsById.delete(agentId);
|
||||
}
|
||||
@@ -248,6 +260,8 @@ export class AgentStorage {
|
||||
this.cache.clear();
|
||||
this.pathById.clear();
|
||||
this.pathsById.clear();
|
||||
this.daemonAgentIdsByExecution.clear();
|
||||
this.daemonExecutionKeysByAgentId.clear();
|
||||
|
||||
try {
|
||||
const records = await this.scanDisk();
|
||||
@@ -266,7 +280,7 @@ export class AgentStorage {
|
||||
|
||||
private async scanDisk(): Promise<StoredAgentRecord[]> {
|
||||
const records: StoredAgentRecord[] = [];
|
||||
let entries: Array<import("node:fs").Dirent> = [];
|
||||
let entries: Dirent[] = [];
|
||||
try {
|
||||
entries = await fs.readdir(this.baseDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
@@ -310,6 +324,7 @@ export class AgentStorage {
|
||||
const { record, filePath } = item;
|
||||
records.push(record);
|
||||
this.cache.set(record.id, record);
|
||||
this.indexOwner(record);
|
||||
this.pathById.set(record.id, filePath);
|
||||
this.addIndexedPath(record.id, filePath);
|
||||
}
|
||||
@@ -350,6 +365,28 @@ export class AgentStorage {
|
||||
}
|
||||
}
|
||||
|
||||
private indexOwner(record: StoredAgentRecord): void {
|
||||
this.removeOwnerIndex(record.id);
|
||||
if (record.owner?.kind === "daemon") {
|
||||
const key = daemonExecutionKey(record.owner);
|
||||
const previousAgentId = this.daemonAgentIdsByExecution.get(key);
|
||||
if (previousAgentId && previousAgentId !== record.id) {
|
||||
this.daemonExecutionKeysByAgentId.delete(previousAgentId);
|
||||
}
|
||||
this.daemonAgentIdsByExecution.set(key, record.id);
|
||||
this.daemonExecutionKeysByAgentId.set(record.id, key);
|
||||
}
|
||||
}
|
||||
|
||||
private removeOwnerIndex(agentId: string): void {
|
||||
const key = this.daemonExecutionKeysByAgentId.get(agentId);
|
||||
if (!key) return;
|
||||
if (this.daemonAgentIdsByExecution.get(key) === agentId) {
|
||||
this.daemonAgentIdsByExecution.delete(key);
|
||||
}
|
||||
this.daemonExecutionKeysByAgentId.delete(agentId);
|
||||
}
|
||||
|
||||
private async waitForPendingWrite(agentId: string): Promise<void> {
|
||||
await (this.pendingWrites.get(agentId) ?? Promise.resolve()).catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import type { AgentManagerEvent, AgentSubscriber } from "./agent-manager.js";
|
||||
import { registerAgentAutoArchive } from "./create-agent-lifecycle-dispatch.js";
|
||||
|
||||
class AgentLifecycleEvents {
|
||||
private readonly listeners = new Set<AgentSubscriber>();
|
||||
|
||||
subscribe(listener: AgentSubscriber): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
completeTurn(agentId: string): void {
|
||||
const event: AgentManagerEvent = {
|
||||
type: "agent_stream",
|
||||
agentId,
|
||||
event: { type: "turn_completed", provider: "codex" },
|
||||
};
|
||||
for (const listener of this.listeners) listener(event);
|
||||
}
|
||||
|
||||
listenerCount(): number {
|
||||
return this.listeners.size;
|
||||
}
|
||||
}
|
||||
|
||||
test("auto-archive self-releases once and later cancellation waits harmlessly", async () => {
|
||||
const agentId = "4a7e2521-286d-4ad5-af35-e091c55302e3";
|
||||
const agents = new AgentLifecycleEvents();
|
||||
let archiveCount = 0;
|
||||
const registration = registerAgentAutoArchive({
|
||||
agentManager: agents,
|
||||
agentId,
|
||||
archive: async () => {
|
||||
archiveCount += 1;
|
||||
},
|
||||
});
|
||||
|
||||
agents.completeTurn(agentId);
|
||||
await registration.cancel();
|
||||
await registration.cancel();
|
||||
agents.completeTurn(agentId);
|
||||
|
||||
expect(archiveCount).toBe(1);
|
||||
expect(agents.listenerCount()).toBe(0);
|
||||
});
|
||||
@@ -3,11 +3,7 @@ import type pino from "pino";
|
||||
|
||||
import type { ForgeService } from "../../services/forge-service.js";
|
||||
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
|
||||
import {
|
||||
archiveByScope,
|
||||
type ActiveWorkspaceRef,
|
||||
resolveWorkspaceIdAtPath,
|
||||
} from "../workspace-archive-service.js";
|
||||
import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-service.js";
|
||||
import type {
|
||||
CreatePaseoWorktreeWorkflowFn,
|
||||
CreatePaseoWorktreeWorkflowResult,
|
||||
@@ -18,7 +14,7 @@ import type {
|
||||
FirstAgentContext,
|
||||
SessionOutboundMessage,
|
||||
} from "../messages.js";
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import type { AgentManager, AgentSubscriber, SubscribeOptions } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
|
||||
interface CreateAgentLifecycleDispatchDependencies {
|
||||
@@ -42,6 +38,20 @@ interface CreateAgentLifecycleDispatchDependencies {
|
||||
logger: pino.Logger;
|
||||
}
|
||||
|
||||
export interface LifecycleRegistration {
|
||||
cancel(): Promise<void>;
|
||||
}
|
||||
|
||||
interface AgentLifecycleEvents {
|
||||
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void;
|
||||
}
|
||||
|
||||
const inactiveRegistration: LifecycleRegistration = { cancel: async () => undefined };
|
||||
|
||||
type AutoArchiveTarget =
|
||||
| { kind: "agent-only" }
|
||||
| { kind: "created-worktree"; result: CreatePaseoWorktreeWorkflowResult };
|
||||
|
||||
export class CreateAgentLifecycleDispatch {
|
||||
private readonly autoArchiveAgentIds = new Set<string>();
|
||||
|
||||
@@ -67,15 +77,15 @@ export class CreateAgentLifecycleDispatch {
|
||||
autoArchive: boolean | undefined;
|
||||
agentId: string;
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
||||
}): void {
|
||||
}): LifecycleRegistration {
|
||||
if (input.autoArchive !== true) {
|
||||
return;
|
||||
return inactiveRegistration;
|
||||
}
|
||||
|
||||
this.registerAutoArchiveOnTerminalState(input.agentId, {
|
||||
worktreePath: input.createdWorktree?.worktree.worktreePath ?? null,
|
||||
repoRoot: input.createdWorktree?.repoRoot ?? null,
|
||||
});
|
||||
return this.registerAutoArchiveOnTerminalState(
|
||||
input.agentId,
|
||||
toAutoArchiveTarget(input.createdWorktree),
|
||||
);
|
||||
}
|
||||
|
||||
async cleanupCreatedWorktreeAfterFailedAgentCreate(input: {
|
||||
@@ -89,8 +99,7 @@ export class CreateAgentLifecycleDispatch {
|
||||
|
||||
await this.archiveAutoCreatedWorktree({
|
||||
agentId: null,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
createdWorktree,
|
||||
}).catch((archiveError) => {
|
||||
this.dependencies.logger.warn(
|
||||
{
|
||||
@@ -145,42 +154,26 @@ export class CreateAgentLifecycleDispatch {
|
||||
|
||||
private registerAutoArchiveOnTerminalState(
|
||||
agentId: string,
|
||||
options: { worktreePath: string | null; repoRoot: string | null },
|
||||
): void {
|
||||
const unsubscribe = this.dependencies.agentManager.subscribe(
|
||||
(event) => {
|
||||
if (event.type !== "agent_stream") {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.event.type !== "turn_completed" &&
|
||||
event.event.type !== "turn_failed" &&
|
||||
event.event.type !== "turn_canceled"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
unsubscribe();
|
||||
void this.autoArchiveAgentOnce(agentId, options);
|
||||
},
|
||||
{ agentId, replayState: false },
|
||||
);
|
||||
target: AutoArchiveTarget,
|
||||
): LifecycleRegistration {
|
||||
return registerAgentAutoArchive({
|
||||
agentManager: this.dependencies.agentManager,
|
||||
agentId,
|
||||
archive: () => this.autoArchiveAgentOnce(agentId, target),
|
||||
});
|
||||
}
|
||||
|
||||
private async autoArchiveAgentOnce(
|
||||
agentId: string,
|
||||
options: { worktreePath: string | null; repoRoot: string | null },
|
||||
): Promise<void> {
|
||||
private async autoArchiveAgentOnce(agentId: string, target: AutoArchiveTarget): Promise<void> {
|
||||
if (this.autoArchiveAgentIds.has(agentId)) {
|
||||
return;
|
||||
}
|
||||
this.autoArchiveAgentIds.add(agentId);
|
||||
|
||||
try {
|
||||
if (options.worktreePath) {
|
||||
if (target.kind === "created-worktree") {
|
||||
await this.archiveAutoCreatedWorktree({
|
||||
agentId,
|
||||
worktreePath: options.worktreePath,
|
||||
repoRoot: options.repoRoot,
|
||||
createdWorktree: target.result,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -193,10 +186,11 @@ export class CreateAgentLifecycleDispatch {
|
||||
|
||||
private async archiveAutoCreatedWorktree(options: {
|
||||
agentId: string | null;
|
||||
worktreePath: string;
|
||||
repoRoot: string | null;
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult;
|
||||
}): Promise<void> {
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(options.worktreePath, {
|
||||
const { createdWorktree } = options;
|
||||
const worktreePath = createdWorktree.worktree.worktreePath;
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(worktreePath, {
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
worktreesRoot: this.dependencies.worktreesRoot,
|
||||
});
|
||||
@@ -204,49 +198,76 @@ export class CreateAgentLifecycleDispatch {
|
||||
throw new Error("Auto-created worktree is not a Paseo-owned worktree");
|
||||
}
|
||||
|
||||
const workspaceId = await resolveWorkspaceIdAtPath(
|
||||
await archiveByScope(
|
||||
{
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
|
||||
github: this.dependencies.github,
|
||||
workspaceGitService: this.dependencies.workspaceGitService,
|
||||
agentManager: this.dependencies.agentManager,
|
||||
agentStorage: this.dependencies.agentStorage,
|
||||
findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd,
|
||||
listActiveWorkspaces: this.dependencies.listActiveWorkspaces,
|
||||
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
|
||||
clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving,
|
||||
killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace,
|
||||
sessionLogger: this.dependencies.logger,
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: createdWorktree.workspace.workspaceId },
|
||||
requestId: randomUUID(),
|
||||
},
|
||||
options.worktreePath,
|
||||
);
|
||||
|
||||
if (!workspaceId) {
|
||||
this.dependencies.logger.warn(
|
||||
{ worktreePath: options.worktreePath },
|
||||
"Could not resolve workspace for auto-archive; skipping",
|
||||
);
|
||||
} else {
|
||||
await archiveByScope(
|
||||
{
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
|
||||
github: this.dependencies.github,
|
||||
workspaceGitService: this.dependencies.workspaceGitService,
|
||||
agentManager: this.dependencies.agentManager,
|
||||
agentStorage: this.dependencies.agentStorage,
|
||||
findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd,
|
||||
listActiveWorkspaces: this.dependencies.listActiveWorkspaces,
|
||||
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForWorkspaceIds:
|
||||
this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
|
||||
clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving,
|
||||
killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace,
|
||||
sessionLogger: this.dependencies.logger,
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot: options.repoRoot ?? ownership.repoRoot ?? null,
|
||||
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
|
||||
requestId: randomUUID(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (options.agentId) {
|
||||
this.dependencies.emitAgentRemove(options.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAgentAutoArchive(input: {
|
||||
agentManager: AgentLifecycleEvents;
|
||||
agentId: string;
|
||||
archive: () => Promise<unknown>;
|
||||
}): LifecycleRegistration {
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let archiveTask: Promise<unknown> | null = null;
|
||||
const release = () => {
|
||||
if (!unsubscribe) return;
|
||||
const subscribed = unsubscribe;
|
||||
unsubscribe = null;
|
||||
subscribed();
|
||||
};
|
||||
const registration: LifecycleRegistration = {
|
||||
async cancel() {
|
||||
release();
|
||||
await archiveTask;
|
||||
},
|
||||
};
|
||||
unsubscribe = input.agentManager.subscribe(
|
||||
(event) => {
|
||||
if (event.type !== "agent_stream") return;
|
||||
if (
|
||||
event.event.type !== "turn_completed" &&
|
||||
event.event.type !== "turn_failed" &&
|
||||
event.event.type !== "turn_canceled"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
release();
|
||||
archiveTask = input.archive();
|
||||
},
|
||||
{ agentId: input.agentId, replayState: false },
|
||||
);
|
||||
return registration;
|
||||
}
|
||||
|
||||
function toAutoArchiveTarget(
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null,
|
||||
): AutoArchiveTarget {
|
||||
return createdWorktree
|
||||
? { kind: "created-worktree", result: createdWorktree }
|
||||
: { kind: "agent-only" };
|
||||
}
|
||||
|
||||
@@ -27,12 +27,13 @@ function createRealAgentManager(storage: AgentStorage): AgentManager {
|
||||
// worktree service).
|
||||
function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: string }) {
|
||||
const worktreePath = join(args.repoRoot, "worktree");
|
||||
mkdirSync(worktreePath, { recursive: true });
|
||||
const workspaceCwd = join(worktreePath, "packages", "app");
|
||||
mkdirSync(workspaceCwd, { recursive: true });
|
||||
return async (): Promise<CreatePaseoWorktreeWorkflowResult> =>
|
||||
({
|
||||
worktree: { worktreePath },
|
||||
intent: {},
|
||||
workspace: { workspaceId: args.createdWorkspaceId },
|
||||
workspace: { workspaceId: args.createdWorkspaceId, cwd: workspaceCwd },
|
||||
repoRoot: args.repoRoot,
|
||||
created: true,
|
||||
setupContinuation: { kind: "agent" as const, startAfterAgentCreate: () => {} },
|
||||
@@ -321,6 +322,59 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async
|
||||
|
||||
const storedChild = await storage.get(child.id);
|
||||
expect(storedChild?.workspaceId).toBe("ws-new-worktree");
|
||||
expect(child.cwd).toBe(join(workdir, "worktree", "packages", "app"));
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("mcp create exposes the created worktree before dispatching the initial prompt", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "create-agent-worktree-callback-test-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const agentManager = createRealAgentManager(storage);
|
||||
const createdWorktree = await fakeWorktreeCreator({
|
||||
repoRoot: workdir,
|
||||
createdWorkspaceId: "ws-created-worktree",
|
||||
})();
|
||||
let observed:
|
||||
| {
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
||||
lifecycle: ManagedAgent["lifecycle"] | null;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
try {
|
||||
await createAgentCommand(
|
||||
{
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
providerSnapshotManager: {
|
||||
async resolveCreateConfig() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
createPaseoWorktree: async () => createdWorktree,
|
||||
},
|
||||
{
|
||||
kind: "mcp",
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "worktree callback",
|
||||
initialPrompt: "Say done.",
|
||||
background: true,
|
||||
notifyOnFinish: false,
|
||||
worktree: { worktreeName: "feature", baseBranch: "main" },
|
||||
onCreated: ({ agentId, createdWorktree: callbackWorktree }) => {
|
||||
observed = {
|
||||
createdWorktree: callbackWorktree,
|
||||
lifecycle: agentManager.getAgent(agentId)?.lifecycle ?? null,
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(observed).toEqual({ createdWorktree, lifecycle: "idle" });
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
||||
import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js";
|
||||
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
|
||||
@@ -15,6 +14,7 @@ import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../
|
||||
import type { AgentManager, CreateAgentOptions, ManagedAgent } from "../agent-manager.js";
|
||||
import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
import type { AgentStorage } from "../agent-storage.js";
|
||||
import type { AgentOwner } from "../agent-owner.js";
|
||||
import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
|
||||
import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js";
|
||||
import { resolveCreateAgentTitles } from "../create-agent-title.js";
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
} from "../timeline-append.js";
|
||||
import { resolveCreateAgentIntent } from "./intent.js";
|
||||
|
||||
export interface CreateAgentSessionWorktreeResult {
|
||||
sessionConfig: AgentSessionConfig;
|
||||
@@ -41,7 +42,7 @@ export interface CreateAgentCommandDependencies {
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
terminalManager?: TerminalManager | null;
|
||||
providerSnapshotManager: ProviderSnapshotManager;
|
||||
providerSnapshotManager: Pick<ProviderSnapshotManager, "resolveCreateConfig">;
|
||||
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
||||
// Mints a fresh directory workspace for a cwd and returns its id.
|
||||
ensureWorkspaceForCreate?: EnsureWorkspaceForCreate;
|
||||
@@ -93,6 +94,13 @@ export interface CreateAgentFromMcpInput {
|
||||
notifyOnFinish: boolean;
|
||||
internal?: boolean;
|
||||
detached?: boolean;
|
||||
owner?: AgentOwner;
|
||||
env?: Record<string, string>;
|
||||
onCreated?: (created: {
|
||||
agentId: string;
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
||||
}) => void;
|
||||
onWorktreeCreated?: (createdWorktree: CreatePaseoWorktreeWorkflowResult) => void;
|
||||
callerAgentId?: string;
|
||||
callerContext?: {
|
||||
lockedCwd?: string;
|
||||
@@ -118,6 +126,7 @@ export interface CreateAgentCommandResult {
|
||||
background: boolean;
|
||||
initialPromptStarted: boolean;
|
||||
initialPromptError: unknown | null;
|
||||
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
|
||||
}
|
||||
|
||||
export type BoundCreateAgentCommand = (
|
||||
@@ -158,6 +167,7 @@ interface ResolvedCreateAgent {
|
||||
background: boolean;
|
||||
promptFailure: CreateAgentPromptFailureMode;
|
||||
promptLogger?: Logger;
|
||||
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
|
||||
}
|
||||
|
||||
export async function createAgentCommand(
|
||||
@@ -182,6 +192,9 @@ export async function createAgentCommand(
|
||||
let liveSnapshot = snapshot;
|
||||
let initialPromptStarted = false;
|
||||
let initialPromptError: unknown | null = null;
|
||||
if (input.kind === "mcp") {
|
||||
input.onCreated?.({ agentId: snapshot.id, createdWorktree: resolved.createdWorktree ?? null });
|
||||
}
|
||||
if (resolved.prompt !== undefined) {
|
||||
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot);
|
||||
initialPromptStarted = sendResult.started;
|
||||
@@ -195,6 +208,7 @@ export async function createAgentCommand(
|
||||
agentStorage: dependencies.agentStorage,
|
||||
childAgentId: snapshot.id,
|
||||
callerAgentId: input.callerAgentId,
|
||||
requireParentOwnership: true,
|
||||
logger: dependencies.logger,
|
||||
});
|
||||
}
|
||||
@@ -205,6 +219,7 @@ export async function createAgentCommand(
|
||||
background: resolved.background,
|
||||
initialPromptStarted,
|
||||
initialPromptError,
|
||||
...(resolved.createdWorktree ? { createdWorktree: resolved.createdWorktree } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -294,20 +309,30 @@ async function resolveMcpCreateAgent(
|
||||
? requireParentAgent(dependencies.agentManager, input.callerAgentId)
|
||||
: null;
|
||||
const cwd = resolveMcpInitialCwd(input, parentAgent);
|
||||
const { resolvedCwd, setupContinuation, createdWorkspaceId } = await resolveMcpCwd({
|
||||
dependencies,
|
||||
cwd,
|
||||
worktree: input.worktree,
|
||||
initialPrompt: input.initialPrompt ?? "",
|
||||
});
|
||||
const { resolvedCwd, setupContinuation, createdWorkspaceId, createdWorktree } =
|
||||
await resolveMcpCwd({
|
||||
dependencies,
|
||||
cwd,
|
||||
worktree: input.worktree,
|
||||
initialPrompt: input.initialPrompt ?? "",
|
||||
});
|
||||
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
|
||||
|
||||
const workspaceId = await resolveMcpWorkspaceId({
|
||||
dependencies,
|
||||
input,
|
||||
parentAgent,
|
||||
setupContinuation,
|
||||
createdWorkspaceId,
|
||||
resolvedCwd,
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
explicitWorkspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId,
|
||||
caller: parentAgent
|
||||
? { id: parentAgent.id, cwd: parentAgent.cwd, workspaceId: parentAgent.workspaceId }
|
||||
: null,
|
||||
labels: input.labels,
|
||||
childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels,
|
||||
legacyDetached: input.detached ?? false,
|
||||
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: resolvedCwd }),
|
||||
createWorkspace: async () => ({
|
||||
workspaceId: requireResolvedWorkspaceId(
|
||||
await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt ?? ""),
|
||||
),
|
||||
cwd: resolvedCwd,
|
||||
}),
|
||||
});
|
||||
const resolvedCreateConfig = await resolveMcpProviderCreateConfig({
|
||||
dependencies,
|
||||
@@ -317,30 +342,26 @@ async function resolveMcpCreateAgent(
|
||||
parentAgent,
|
||||
});
|
||||
|
||||
const labels = mergeLabels({
|
||||
callerAgentId: input.callerAgentId,
|
||||
detached: input.detached ?? false,
|
||||
childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels,
|
||||
labels: input.labels,
|
||||
});
|
||||
|
||||
const trimmedPrompt = input.initialPrompt?.trim() ?? "";
|
||||
return {
|
||||
config: buildMcpSessionConfig({
|
||||
input,
|
||||
resolvedProviderModel,
|
||||
provider,
|
||||
resolvedCwd,
|
||||
resolvedCwd: intent.cwd,
|
||||
trimmedPrompt,
|
||||
resolvedMode: resolvedCreateConfig.modeId,
|
||||
resolvedFeatures: resolvedCreateConfig.featureValues,
|
||||
}),
|
||||
createOptions: {
|
||||
...(labels ? { labels } : {}),
|
||||
workspaceId: requireResolvedWorkspaceId(workspaceId),
|
||||
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
|
||||
workspaceId: intent.workspaceId,
|
||||
owner: input.owner,
|
||||
env: input.env,
|
||||
},
|
||||
prompt: trimmedPrompt ? trimmedPrompt : undefined,
|
||||
setupContinuation,
|
||||
createdWorktree,
|
||||
background: input.background,
|
||||
promptFailure: input.promptFailure ?? "log",
|
||||
};
|
||||
@@ -361,34 +382,6 @@ function resolveMcpInitialCwd(
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveMcpWorkspaceId(params: {
|
||||
dependencies: CreateAgentCommandDependencies;
|
||||
input: CreateAgentFromMcpInput;
|
||||
parentAgent: ManagedAgent | null;
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
createdWorkspaceId?: string;
|
||||
resolvedCwd: string;
|
||||
}): Promise<string | undefined> {
|
||||
// MCP callers resolve workspace ownership before this point. Worktree
|
||||
// creation wins because the new agent lives in the fresh worktree workspace.
|
||||
// Otherwise use the explicit workspace id, then the parent workspace for
|
||||
// direct internal callers. Ownership is never resolved from cwd.
|
||||
if (params.setupContinuation) {
|
||||
return params.createdWorkspaceId;
|
||||
}
|
||||
if (params.input.workspaceId) {
|
||||
return params.input.workspaceId;
|
||||
}
|
||||
if (params.parentAgent?.workspaceId) {
|
||||
return params.parentAgent.workspaceId;
|
||||
}
|
||||
return ensureWorkspaceForMcpCreate(
|
||||
params.dependencies,
|
||||
params.resolvedCwd,
|
||||
params.input.initialPrompt ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveMcpProviderCreateConfig(params: {
|
||||
dependencies: CreateAgentCommandDependencies;
|
||||
input: CreateAgentFromMcpInput;
|
||||
@@ -518,6 +511,7 @@ async function resolveMcpCwd(params: {
|
||||
resolvedCwd: string;
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
createdWorkspaceId?: string;
|
||||
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
|
||||
}> {
|
||||
const { dependencies, worktree } = params;
|
||||
if (!worktree) {
|
||||
@@ -573,9 +567,10 @@ async function resolveMcpCwd(params: {
|
||||
},
|
||||
});
|
||||
return {
|
||||
resolvedCwd: createdWorktree.worktree.worktreePath,
|
||||
resolvedCwd: createdWorktree.workspace.cwd,
|
||||
setupContinuation: createdWorktree.setupContinuation,
|
||||
createdWorkspaceId: createdWorktree.workspace.workspaceId,
|
||||
createdWorktree,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -603,22 +598,3 @@ async function createMcpWorktree(
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function mergeLabels(params: {
|
||||
callerAgentId: string | undefined;
|
||||
detached: boolean;
|
||||
childAgentDefaultLabels: Record<string, string> | undefined;
|
||||
labels: Record<string, string> | undefined;
|
||||
}): Record<string, string> | undefined {
|
||||
const mergedLabels = {
|
||||
...(!params.detached && params.callerAgentId
|
||||
? { [PARENT_AGENT_ID_LABEL]: params.callerAgentId }
|
||||
: {}),
|
||||
...params.childAgentDefaultLabels,
|
||||
...params.labels,
|
||||
};
|
||||
if (params.detached) {
|
||||
delete mergedLabels[PARENT_AGENT_ID_LABEL];
|
||||
}
|
||||
return Object.keys(mergedLabels).length > 0 ? mergedLabels : undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { resolveCreateAgentIntent } from "./intent.js";
|
||||
|
||||
describe("resolveCreateAgentIntent", () => {
|
||||
it("keeps caller parentage when an explicit workspace changes placement", async () => {
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
explicitWorkspaceId: "workspace-isolated",
|
||||
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
|
||||
labels: { purpose: "review" },
|
||||
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/isolated" }),
|
||||
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
workspaceId: "workspace-isolated",
|
||||
cwd: "/isolated",
|
||||
parentAgentId: "parent-agent",
|
||||
labels: {
|
||||
purpose: "review",
|
||||
[PARENT_AGENT_ID_LABEL]: "parent-agent",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults an agent caller to its workspace without creating one", async () => {
|
||||
let createCount = 0;
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
|
||||
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
|
||||
createWorkspace: async () => {
|
||||
createCount += 1;
|
||||
return { workspaceId: "workspace-created", cwd: "/created" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(intent.workspaceId).toBe("workspace-parent");
|
||||
expect(intent.cwd).toBe("/parent");
|
||||
expect(intent.parentAgentId).toBe("parent-agent");
|
||||
expect(createCount).toBe(0);
|
||||
});
|
||||
|
||||
it("creates a workspace for a human caller with no workspace context", async () => {
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
caller: null,
|
||||
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
|
||||
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
workspaceId: "workspace-created",
|
||||
cwd: "/created",
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy detached creation independent", async () => {
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: "spoofed-parent" },
|
||||
legacyDetached: true,
|
||||
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
|
||||
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
workspaceId: "workspace-parent",
|
||||
cwd: "/parent",
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
|
||||
export interface CreateAgentCaller {
|
||||
id: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentPlacement {
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentIntent {
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
parentAgentId: string | null;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function resolveCreateAgentIntent(input: {
|
||||
explicitWorkspaceId?: string;
|
||||
caller: CreateAgentCaller | null;
|
||||
labels?: Record<string, string>;
|
||||
childAgentDefaultLabels?: Record<string, string>;
|
||||
resolveWorkspace: (workspaceId: string) => Promise<CreateAgentPlacement>;
|
||||
createWorkspace: () => Promise<CreateAgentPlacement>;
|
||||
legacyDetached?: boolean;
|
||||
}): Promise<CreateAgentIntent> {
|
||||
const parentAgentId = input.legacyDetached ? null : (input.caller?.id ?? null);
|
||||
const placement = await resolvePlacement(input);
|
||||
const labels = {
|
||||
...input.childAgentDefaultLabels,
|
||||
...input.labels,
|
||||
...(parentAgentId ? { [PARENT_AGENT_ID_LABEL]: parentAgentId } : {}),
|
||||
};
|
||||
|
||||
// COMPAT(detachedCreate): legacy callers may still request detached creation.
|
||||
// Added in v0.2.0; remove after 2027-01-17 once detached creation is outside the floor.
|
||||
// The delete also strips a parent label injected through input.labels.
|
||||
if (input.legacyDetached) {
|
||||
delete labels[PARENT_AGENT_ID_LABEL];
|
||||
}
|
||||
|
||||
return { ...placement, parentAgentId, labels };
|
||||
}
|
||||
|
||||
async function resolvePlacement(input: {
|
||||
explicitWorkspaceId?: string;
|
||||
caller: CreateAgentCaller | null;
|
||||
resolveWorkspace: (workspaceId: string) => Promise<CreateAgentPlacement>;
|
||||
createWorkspace: () => Promise<CreateAgentPlacement>;
|
||||
}): Promise<CreateAgentPlacement> {
|
||||
if (input.explicitWorkspaceId) {
|
||||
return input.resolveWorkspace(input.explicitWorkspaceId);
|
||||
}
|
||||
if (input.caller) {
|
||||
if (!input.caller.workspaceId) {
|
||||
throw new Error(`Caller agent ${input.caller.id} has no workspace`);
|
||||
}
|
||||
return { workspaceId: input.caller.workspaceId, cwd: input.caller.cwd };
|
||||
}
|
||||
return input.createWorkspace();
|
||||
}
|
||||
@@ -22,9 +22,12 @@ import {
|
||||
AgentSnapshotPayloadSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
type ProjectRegistry,
|
||||
type WorkspaceRegistry,
|
||||
} from "../workspace-registry.js";
|
||||
import type {
|
||||
CreateScheduleInput,
|
||||
@@ -46,11 +49,13 @@ import { WorkspaceAutoName } from "../workspace-auto-name.js";
|
||||
import { createGitMutationService } from "../session/git-mutation/git-mutation-service.js";
|
||||
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
|
||||
import type { ForgeService } from "../../services/forge-service.js";
|
||||
import { areEquivalentPaths } from "../../utils/path.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
|
||||
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
|
||||
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js";
|
||||
import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
|
||||
const REPO_CWD = resolvePath("/tmp/repo");
|
||||
const TARGET_CWD = resolvePath("/tmp/target");
|
||||
@@ -670,13 +675,68 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
paseoHome: options.paseoHome,
|
||||
deps: { forgeOverrides: { github } },
|
||||
});
|
||||
const workspaceRegistry = {
|
||||
const projectRegistry: ProjectRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (projectId) => projects.get(projectId) ?? null,
|
||||
getOrCreateActiveByRoot: async (allocation) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) =>
|
||||
areEquivalentPaths(project.rootPath, allocation.rootPath) && !project.archivedAt,
|
||||
);
|
||||
if (existing) return existing;
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: `prj_test_${projects.size + 1}`,
|
||||
rootPath: allocation.rootPath,
|
||||
kind: allocation.kind,
|
||||
displayName: allocation.displayName,
|
||||
createdAt: allocation.timestamp,
|
||||
updatedAt: allocation.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
},
|
||||
upsert: async (record) => {
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
archive: async (projectId, archivedAt) => {
|
||||
const project = projects.get(projectId);
|
||||
if (project) projects.set(projectId, { ...project, archivedAt });
|
||||
},
|
||||
remove: async (projectId) => {
|
||||
projects.delete(projectId);
|
||||
},
|
||||
};
|
||||
const workspaceRegistry: WorkspaceRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
update: async (workspaceId, updater) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
if (!workspace) return null;
|
||||
const updated = updater(workspace);
|
||||
workspaces.set(workspaceId, updated);
|
||||
return updated;
|
||||
},
|
||||
upsert: async (record: PersistedWorkspaceRecord) => {
|
||||
workspaces.set(record.workspaceId, record);
|
||||
},
|
||||
archive: async (workspaceId, archivedAt) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
if (workspace) workspaces.set(workspaceId, { ...workspace, archivedAt });
|
||||
},
|
||||
remove: async (workspaceId) => {
|
||||
workspaces.delete(workspaceId);
|
||||
},
|
||||
};
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const workspaceAutoName = new WorkspaceAutoName({
|
||||
agentManager: buildAgentManagerSpies() as unknown as AgentManager,
|
||||
workspaceRegistry,
|
||||
@@ -710,14 +770,8 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
...(workflowOptions?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: workflowOptions.resolveDefaultBranch }
|
||||
: {}),
|
||||
projectRegistry: {
|
||||
get: async (projectId) => projects.get(projectId) ?? null,
|
||||
upsert: async (record) => {
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
},
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
workspaceProvisioning,
|
||||
}),
|
||||
warmWorkspaceGitData: async () => {},
|
||||
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
|
||||
@@ -1148,6 +1202,42 @@ describe("create_agent MCP tool", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("creates a fresh local workspace for canonical top-level creation", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "top-level-agent",
|
||||
provider: "codex",
|
||||
cwd: existingCwd,
|
||||
workspaceId: "workspace-created",
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Top-level agent" },
|
||||
} as ManagedAgent);
|
||||
const ensureWorkspace = vi.fn(async () => "workspace-created");
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
ensureWorkspaceForCreate: ensureWorkspace,
|
||||
logger,
|
||||
});
|
||||
|
||||
await registeredTool(server, "create_agent").handler({
|
||||
title: "Top-level agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: true,
|
||||
});
|
||||
|
||||
expect(ensureWorkspace).toHaveBeenCalledWith(existingCwd, { prompt: "Do work" });
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: existingCwd }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects partial explicit workspace shape", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -1454,7 +1544,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts each create_worktree target kind", async () => {
|
||||
it("exposes workspace tools instead of worktree tools", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
@@ -1462,31 +1552,14 @@ describe("create_agent MCP tool", () => {
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
|
||||
for (const target of [
|
||||
{ kind: "branch-off", worktreeSlug: "feature-x", baseBranch: "main" },
|
||||
{ kind: "branch-off" },
|
||||
{ kind: "checkout-branch", branch: "head-ref" },
|
||||
{ kind: "checkout-pr", githubPrNumber: 42 },
|
||||
] as const) {
|
||||
const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, target });
|
||||
expect(parsed.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects create_worktree without a target", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync({});
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(lookupTool(server, "create_workspace")).toBeDefined();
|
||||
expect(lookupTool(server, "list_workspaces")).toBeDefined();
|
||||
expect(lookupTool(server, "archive_workspace")).toBeDefined();
|
||||
expect(lookupTool(server, "create_worktree")).toBeUndefined();
|
||||
expect(lookupTool(server, "list_worktrees")).toBeUndefined();
|
||||
expect(lookupTool(server, "archive_worktree")).toBeUndefined();
|
||||
expect(lookupTool(server, "detach_agent")).toBeUndefined();
|
||||
expect(lookupTool(server, "update_heartbeat")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces createAgent validation failures", async () => {
|
||||
@@ -2389,7 +2462,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers and broadcasts a workspace when create_worktree creates a worktree", async () => {
|
||||
it("creates a worktree-isolated workspace", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
@@ -2434,25 +2507,19 @@ describe("create_agent MCP tool", () => {
|
||||
>,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
const tool = registeredTool(server, "create_workspace");
|
||||
const response = await tool.handler({
|
||||
cwd: repoDir,
|
||||
target: {
|
||||
kind: "branch-off",
|
||||
worktreeSlug: "tool-worktree",
|
||||
branchName: "feature/tool-worktree",
|
||||
baseBranch: "main",
|
||||
},
|
||||
isolation: "worktree",
|
||||
path: repoDir,
|
||||
worktreeSlug: "tool-worktree",
|
||||
branchName: "feature/tool-worktree",
|
||||
baseBranch: "main",
|
||||
});
|
||||
|
||||
expect(response.structuredContent.branchName).toBe("feature/tool-worktree");
|
||||
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
|
||||
expect(response.structuredContent.isolation).toBe("worktree");
|
||||
expect(response.structuredContent.cwd).toContain("tool-worktree");
|
||||
expect(response.structuredContent.workspaceId).toBe(broadcasts[0]);
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
|
||||
force: true,
|
||||
reason: "mcp:create-worktree",
|
||||
});
|
||||
expect(setupContinuations).toEqual([undefined]);
|
||||
expect(broadcasts).toHaveLength(1);
|
||||
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
|
||||
@@ -2461,7 +2528,149 @@ describe("create_agent MCP tool", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
|
||||
it("creates a worktree workspace from a project root without a path", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "project-source",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "source",
|
||||
createdAt: "2026-07-18T00:00:00.000Z",
|
||||
updatedAt: "2026-07-18T00:00:00.000Z",
|
||||
});
|
||||
const receivedInputs: CreatePaseoWorktreeInput[] = [];
|
||||
const createPaseoWorktree: CreatePaseoWorktreeWorkflowFn = async (input) => {
|
||||
receivedInputs.push(input);
|
||||
return {
|
||||
worktree: { branchName: "project-worktree", worktreePath: TARGET_CWD },
|
||||
intent: { kind: "branch-off", branchName: "project-worktree", baseBranch: "main" },
|
||||
workspace: createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-project-source",
|
||||
projectId: project.projectId,
|
||||
cwd: TARGET_CWD,
|
||||
kind: "worktree",
|
||||
displayName: "project-worktree",
|
||||
title: input.title ?? null,
|
||||
createdAt: "2026-07-18T00:00:00.000Z",
|
||||
updatedAt: "2026-07-18T00:00:00.000Z",
|
||||
}),
|
||||
repoRoot: REPO_CWD,
|
||||
created: true,
|
||||
};
|
||||
};
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
projectRegistry: {
|
||||
get: async (projectId) => (projectId === project.projectId ? project : null),
|
||||
},
|
||||
createPaseoWorktree,
|
||||
logger,
|
||||
});
|
||||
|
||||
const response = await invokeToolWithParsedInput(registeredTool(server, "create_workspace"), {
|
||||
isolation: "worktree",
|
||||
projectId: project.projectId,
|
||||
worktreeSlug: "project-worktree",
|
||||
title: "Project workspace",
|
||||
});
|
||||
|
||||
expect(response.structuredContent.workspaceId).toBe("ws-project-source");
|
||||
expect(receivedInputs).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: REPO_CWD,
|
||||
projectId: project.projectId,
|
||||
title: "Project workspace",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves branch checkout and pull request checkout workspace modes", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const createPaseoWorktree = vi.fn(async (input: CreatePaseoWorktreeInput) => ({
|
||||
worktree: {
|
||||
branchName: input.refName ?? "pr-42",
|
||||
worktreePath: "/tmp/worktrees/selected",
|
||||
},
|
||||
intent: {
|
||||
kind: "checkout-branch" as const,
|
||||
branchName: input.refName ?? "pr-42",
|
||||
},
|
||||
workspace: createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-selected",
|
||||
projectId: "project-1",
|
||||
cwd: "/tmp/worktrees/selected",
|
||||
kind: "worktree",
|
||||
displayName: "selected",
|
||||
createdAt: "2026-07-18T00:00:00.000Z",
|
||||
updatedAt: "2026-07-18T00:00:00.000Z",
|
||||
}),
|
||||
repoRoot: REPO_CWD,
|
||||
created: true,
|
||||
}));
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
createPaseoWorktree,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_workspace");
|
||||
|
||||
await invokeToolWithParsedInput(tool, {
|
||||
isolation: "worktree",
|
||||
path: REPO_CWD,
|
||||
mode: "checkout-branch",
|
||||
branch: "existing-work",
|
||||
worktreeSlug: "existing-work-copy",
|
||||
});
|
||||
await invokeToolWithParsedInput(tool, {
|
||||
isolation: "worktree",
|
||||
path: REPO_CWD,
|
||||
mode: "checkout-pr",
|
||||
prNumber: 42,
|
||||
forge: "gitlab",
|
||||
});
|
||||
await invokeToolWithParsedInput(tool, {
|
||||
isolation: "worktree",
|
||||
path: REPO_CWD,
|
||||
mode: "checkout-pr",
|
||||
prNumber: 43,
|
||||
});
|
||||
|
||||
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
action: "checkout",
|
||||
refName: "existing-work",
|
||||
worktreeSlug: "existing-work-copy",
|
||||
}),
|
||||
);
|
||||
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
action: "checkout",
|
||||
checkoutSource: {
|
||||
kind: "change_request",
|
||||
forge: "gitlab",
|
||||
number: 42,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
action: "checkout",
|
||||
checkoutSource: {
|
||||
kind: "change_request",
|
||||
number: 43,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("archives a worktree-isolated workspace by workspace id", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const tempDir = realpathSync.native(
|
||||
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
|
||||
@@ -2514,13 +2723,15 @@ describe("create_agent MCP tool", () => {
|
||||
github: createGitHubServiceStub(),
|
||||
logger,
|
||||
});
|
||||
const createTool = registeredTool(server, "create_worktree");
|
||||
const archiveTool = registeredTool(server, "archive_worktree");
|
||||
const createTool = registeredTool(server, "create_workspace");
|
||||
const archiveTool = registeredTool(server, "archive_workspace");
|
||||
const created = await createTool.handler({
|
||||
cwd: repoDir,
|
||||
target: { kind: "branch-off", worktreeSlug: "archive-tool-worktree", baseBranch: "main" },
|
||||
isolation: "worktree",
|
||||
path: repoDir,
|
||||
worktreeSlug: "archive-tool-worktree",
|
||||
baseBranch: "main",
|
||||
});
|
||||
const createdWorktreePath = z.string().parse(created.structuredContent.worktreePath);
|
||||
const createdWorktreePath = z.string().parse(created.structuredContent.cwd);
|
||||
listActiveWorkspaces.mockImplementation(async () => [
|
||||
{ workspaceId: "ws-archive-tool-worktree", cwd: createdWorktreePath, kind: "worktree" },
|
||||
]);
|
||||
@@ -2530,19 +2741,13 @@ describe("create_agent MCP tool", () => {
|
||||
workspaceGitService.getSnapshot.mockClear();
|
||||
|
||||
await archiveTool.handler({
|
||||
cwd: repoDir,
|
||||
worktreePath: created.structuredContent.worktreePath,
|
||||
workspaceId: "ws-archive-tool-worktree",
|
||||
});
|
||||
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
|
||||
force: true,
|
||||
reason: "archive-worktree",
|
||||
});
|
||||
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir);
|
||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
|
||||
force: true,
|
||||
reason: "mcp:archive-worktree",
|
||||
});
|
||||
expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree");
|
||||
expect(markWorkspaceArchiving).toHaveBeenCalledWith(
|
||||
["ws-archive-tool-worktree"],
|
||||
@@ -2557,7 +2762,22 @@ describe("create_agent MCP tool", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("archives every workspace on a directory and removes the directory", async () => {
|
||||
it("rejects archiving a missing workspace", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
listActiveWorkspaces: async () => [],
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(
|
||||
registeredTool(server, "archive_workspace").handler({ workspaceId: "missing-workspace" }),
|
||||
).rejects.toThrow("Workspace not found: missing-workspace");
|
||||
});
|
||||
|
||||
it("keeps an owned worktree while another workspace still references it", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const tempDir = realpathSync.native(
|
||||
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-multi-")),
|
||||
@@ -2616,13 +2836,15 @@ describe("create_agent MCP tool", () => {
|
||||
github: createGitHubServiceStub(),
|
||||
logger,
|
||||
});
|
||||
const createTool = registeredTool(server, "create_worktree");
|
||||
const archiveTool = registeredTool(server, "archive_worktree");
|
||||
const createTool = registeredTool(server, "create_workspace");
|
||||
const archiveTool = registeredTool(server, "archive_workspace");
|
||||
const created = await createTool.handler({
|
||||
cwd: repoDir,
|
||||
target: { kind: "branch-off", worktreeSlug: "archive-multi-worktree", baseBranch: "main" },
|
||||
isolation: "worktree",
|
||||
path: repoDir,
|
||||
worktreeSlug: "archive-multi-worktree",
|
||||
baseBranch: "main",
|
||||
});
|
||||
const worktreePath = z.string().parse(created.structuredContent.worktreePath);
|
||||
const worktreePath = z.string().parse(created.structuredContent.cwd);
|
||||
|
||||
// Populate the active workspaces with the real created path so archiveByScope
|
||||
// matches it against the worktree directory.
|
||||
@@ -2632,19 +2854,18 @@ describe("create_agent MCP tool", () => {
|
||||
];
|
||||
|
||||
await archiveTool.handler({
|
||||
cwd: repoDir,
|
||||
worktreePath,
|
||||
workspaceId: "ws-mcp-A",
|
||||
});
|
||||
|
||||
expect(archivedWorkspaceIds).toContain("ws-mcp-A");
|
||||
expect(archivedWorkspaceIds).toContain("ws-mcp-B");
|
||||
await expect(access(worktreePath)).rejects.toThrow();
|
||||
expect(archivedWorkspaceIds).not.toContain("ws-mcp-B");
|
||||
await expect(access(worktreePath)).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await removeTempDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("archives a worktree by slug", async () => {
|
||||
it("does not expose worktree path or slug operations", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const tempDir = realpathSync.native(
|
||||
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-slug-")),
|
||||
@@ -2692,71 +2913,41 @@ describe("create_agent MCP tool", () => {
|
||||
github: createGitHubServiceStub(),
|
||||
logger,
|
||||
});
|
||||
const createTool = registeredTool(server, "create_worktree");
|
||||
const archiveTool = registeredTool(server, "archive_worktree");
|
||||
const created = await createTool.handler({
|
||||
cwd: repoDir,
|
||||
target: { kind: "branch-off", worktreeSlug: "archive-slug-worktree", baseBranch: "main" },
|
||||
});
|
||||
|
||||
const response = await archiveTool.handler({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "archive-slug-worktree",
|
||||
});
|
||||
|
||||
expect(response.structuredContent).toEqual({ success: true });
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
|
||||
force: true,
|
||||
reason: "archive-worktree",
|
||||
});
|
||||
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir);
|
||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
|
||||
force: true,
|
||||
reason: "mcp:archive-worktree",
|
||||
});
|
||||
await expect(
|
||||
access(z.string().parse(created.structuredContent.worktreePath)),
|
||||
).rejects.toThrow();
|
||||
expect(lookupTool(server, "create_worktree")).toBeUndefined();
|
||||
expect(lookupTool(server, "archive_worktree")).toBeUndefined();
|
||||
} finally {
|
||||
await removeTempDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("routes list_worktrees through WorkspaceGitService", async () => {
|
||||
it("lists active workspace descriptors", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(async () => null),
|
||||
listWorktrees: vi.fn(async () => [
|
||||
{
|
||||
path: "/tmp/paseo/worktrees/repo/feature",
|
||||
branchName: "feature",
|
||||
createdAt: "2026-04-12T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
};
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-feature",
|
||||
projectId: "project-1",
|
||||
cwd: "/tmp/paseo/worktrees/repo/feature",
|
||||
kind: "worktree",
|
||||
displayName: "feature",
|
||||
createdAt: "2026-07-17T00:00:00.000Z",
|
||||
updatedAt: "2026-07-17T00:00:00.000Z",
|
||||
});
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees"
|
||||
>,
|
||||
workspaceRegistry: {
|
||||
get: vi.fn(async () => workspace),
|
||||
list: vi.fn(async () => [workspace]),
|
||||
upsert: vi.fn(async () => undefined),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_worktrees");
|
||||
const tool = registeredTool(server, "list_workspaces");
|
||||
|
||||
const response = await tool.handler({ cwd: REPO_CWD });
|
||||
const response = await tool.handler({});
|
||||
|
||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, {
|
||||
reason: "mcp:list-worktrees",
|
||||
});
|
||||
expect(response.structuredContent.worktrees).toEqual([
|
||||
{
|
||||
path: "/tmp/paseo/worktrees/repo/feature",
|
||||
branchName: "feature",
|
||||
createdAt: "2026-04-12T00:00:00.000Z",
|
||||
},
|
||||
expect(response.structuredContent.workspaces).toEqual([
|
||||
expect.objectContaining({ workspaceId: "ws-feature", isolation: "worktree" }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -3781,11 +3972,11 @@ describe("create_schedule MCP tool", () => {
|
||||
cron: "*/5 * * * *",
|
||||
name: "Default schedule",
|
||||
}),
|
||||
).rejects.toThrow("provider is required when target is new-agent");
|
||||
).rejects.toThrow("provider");
|
||||
expect(createOrReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps create_schedule provider overrides compatible with provider and provider/model forms", async () => {
|
||||
it("keeps provider forms compatible without materializing default schedule isolation", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
||||
createStoredSchedule(input),
|
||||
@@ -3809,6 +4000,12 @@ describe("create_schedule MCP tool", () => {
|
||||
cron: "*/10 * * * *",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
await tool.handler({
|
||||
prompt: "say hello in a worktree",
|
||||
cron: "*/15 * * * *",
|
||||
provider: "codex",
|
||||
isolation: "worktree",
|
||||
});
|
||||
|
||||
expect(createOrReplace).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -3835,9 +4032,22 @@ describe("create_schedule MCP tool", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(createOrReplace).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: process.cwd(),
|
||||
isolation: "worktree",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns create_schedule structured content with inherited feature values", async () => {
|
||||
it("inherits the caller provider, model, and features when provider is omitted", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
@@ -3868,12 +4078,17 @@ describe("create_schedule MCP tool", () => {
|
||||
const response = await tool.handler({
|
||||
prompt: "say hello",
|
||||
cron: "*/5 * * * *",
|
||||
provider: "opencode/openai/gpt-5.5",
|
||||
});
|
||||
|
||||
expect(response.structuredContent.target).toMatchObject({
|
||||
expect(response.structuredContent.target).toEqual({
|
||||
type: "new-agent",
|
||||
config: { featureValues: { auto_accept: true } },
|
||||
config: {
|
||||
provider: "opencode",
|
||||
cwd: REPO_CWD,
|
||||
modeId: "build",
|
||||
model: "openai/gpt-5.5",
|
||||
featureValues: { auto_accept: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4049,6 +4264,60 @@ describe("create_heartbeat MCP tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("heartbeat ownership MCP tools", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("deletes the caller's heartbeat", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const heartbeat = createStoredSchedule({
|
||||
prompt: "check status",
|
||||
cadence: { type: "cron", expression: "*/15 * * * *" },
|
||||
target: { type: "agent", agentId: "parent-agent" },
|
||||
});
|
||||
const inspect = vi.fn(async () => heartbeat);
|
||||
const deleteSchedule = vi.fn(async () => undefined);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: {
|
||||
inspect,
|
||||
delete: deleteSchedule,
|
||||
} as unknown as ScheduleService,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
|
||||
await registeredTool(server, "delete_heartbeat").handler({ id: heartbeat.id });
|
||||
|
||||
expect(deleteSchedule).toHaveBeenCalledWith(heartbeat.id);
|
||||
});
|
||||
|
||||
it("rejects another agent's heartbeat", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const foreignHeartbeat = createStoredSchedule({
|
||||
prompt: "foreign",
|
||||
cadence: { type: "cron", expression: "0 * * * *" },
|
||||
target: { type: "agent", agentId: "other-agent" },
|
||||
});
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: {
|
||||
inspect: vi.fn(async () => foreignHeartbeat),
|
||||
delete: vi.fn(),
|
||||
} as unknown as ScheduleService,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(
|
||||
registeredTool(server, "delete_heartbeat").handler({ id: foreignHeartbeat.id }),
|
||||
).rejects.toThrow("does not belong to caller");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update_schedule MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
@@ -4071,6 +4340,16 @@ describe("update_schedule MCP tool", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleServiceWithUpdate(
|
||||
update: (input: UpdateScheduleInput) => Promise<StoredSchedule>,
|
||||
stored = makeStoredSchedule(),
|
||||
): ScheduleService {
|
||||
return {
|
||||
update,
|
||||
inspect: vi.fn(async () => stored),
|
||||
} as unknown as ScheduleService;
|
||||
}
|
||||
|
||||
it("calls scheduleService.update with correct input", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const stored = makeStoredSchedule();
|
||||
@@ -4084,7 +4363,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4110,7 +4389,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4122,7 +4401,7 @@ describe("update_schedule MCP tool", () => {
|
||||
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
id: "schedule-1",
|
||||
cadence: { type: "every", everyMs: 600000 },
|
||||
cadence: { type: "cron", expression: "*/10 * * * *" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4134,7 +4413,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4163,7 +4442,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4176,7 +4455,7 @@ describe("update_schedule MCP tool", () => {
|
||||
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
id: "schedule-1",
|
||||
cadence: { type: "every", everyMs: 600000 },
|
||||
cadence: { type: "cron", expression: "*/10 * * * *" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4184,7 +4463,7 @@ describe("update_schedule MCP tool", () => {
|
||||
{
|
||||
label: "whitespace cron field",
|
||||
input: { id: "schedule-1", every: "10m", cron: " " },
|
||||
cadence: { type: "every", everyMs: 600000 },
|
||||
cadence: { type: "cron", expression: "*/10 * * * *" },
|
||||
},
|
||||
{
|
||||
label: "blank every field for cron cadence",
|
||||
@@ -4204,7 +4483,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4224,7 +4503,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4246,7 +4525,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4269,7 +4548,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4293,7 +4572,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4327,7 +4606,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update, stored),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4358,7 +4637,7 @@ describe("update_schedule MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
scheduleService: scheduleServiceWithUpdate(update),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
@@ -4401,11 +4680,15 @@ describe("schedule_logs MCP tool", () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const runs = [makeRun({ id: "run-1" }), makeRun({ id: "run-2", status: "failed" })];
|
||||
const logs = vi.fn(async (_id: string) => runs);
|
||||
const inspect = vi.fn(async () => ({
|
||||
id: "schedule-1",
|
||||
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp" } },
|
||||
}));
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { logs } as unknown as ScheduleService,
|
||||
scheduleService: { logs, inspect } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "schedule_logs");
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { AgentProviderNotice } from "./agent-sdk-types.js";
|
||||
|
||||
export const SETTING_APPLIES_NEXT_TURN_NOTICE: AgentProviderNotice = {
|
||||
type: "info",
|
||||
message: "This change applies next turn.",
|
||||
export const MODE_APPLIES_NEXT_TURN_NOTICE: AgentProviderNotice = {
|
||||
type: "warning",
|
||||
message: "Permission mode applies next turn",
|
||||
};
|
||||
|
||||
export const THINKING_APPLIES_NEXT_TURN_NOTICE: AgentProviderNotice = {
|
||||
type: "warning",
|
||||
message: "Thinking level applies next turn",
|
||||
};
|
||||
|
||||
@@ -814,7 +814,7 @@ test("enabled: false keeps provider metadata in registry", () => {
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
description: "Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning",
|
||||
defaultModeId: "default",
|
||||
defaultModeId: "auto",
|
||||
enabled: false,
|
||||
});
|
||||
expect(registry.claude.modes).toEqual(
|
||||
@@ -1487,6 +1487,31 @@ describe("fetchCatalog", () => {
|
||||
expect(catalog.models.map((model) => model.id)).toEqual(["profile-model", "extra-model"]);
|
||||
});
|
||||
|
||||
test("replacement models still resolve the provider's capability-aware default mode", async () => {
|
||||
const resolveDefaultModeId = vi.fn(async () => "default");
|
||||
const injectedClient = {
|
||||
provider: "codex",
|
||||
capabilities: {},
|
||||
resolveDefaultModeId,
|
||||
isAvailable: vi.fn(async () => true),
|
||||
} satisfies Partial<AgentClient> as AgentClient;
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
codex: { models: [{ id: "profile-model", label: "Profile Model" }] },
|
||||
},
|
||||
});
|
||||
|
||||
const catalog = await registry.codex.fetchCatalog(
|
||||
{ scope: "workspace", cwd: "/tmp/catalog", force: false },
|
||||
injectedClient,
|
||||
);
|
||||
|
||||
expect(catalog.defaultModeId).toBe("default");
|
||||
expect(resolveDefaultModeId).toHaveBeenCalledWith({
|
||||
config: { provider: "codex", cwd: "/tmp/catalog" },
|
||||
});
|
||||
});
|
||||
|
||||
test("additionalModels can override replacement model fields", async () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ProviderCatalog,
|
||||
ResolveAgentCreateConfigInput,
|
||||
ResolveAgentCreateConfigResult,
|
||||
ResolveAgentDefaultModeInput,
|
||||
} from "./agent-sdk-types.js";
|
||||
import {
|
||||
isDefaultAgentCreateConfigUnattended,
|
||||
@@ -427,12 +428,20 @@ function wrapClientProvider(
|
||||
fetchCatalog: async (options) => {
|
||||
const catalog = await inner.fetchCatalog(options);
|
||||
return {
|
||||
...catalog,
|
||||
models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
|
||||
profileModelsAreAdditive,
|
||||
}),
|
||||
modes: catalog.modes,
|
||||
};
|
||||
},
|
||||
resolveDefaultModeId: inner.resolveDefaultModeId
|
||||
? async ({ config, env }: ResolveAgentDefaultModeInput) =>
|
||||
await inner.resolveDefaultModeId?.({
|
||||
config: { ...config, provider: inner.provider },
|
||||
env,
|
||||
})
|
||||
: undefined,
|
||||
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
|
||||
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
|
||||
listFeatures: listFeatures
|
||||
@@ -516,17 +525,25 @@ function createRegistryEntry(
|
||||
// the single catalog API; otherwise use static/empty modes with no runtime.
|
||||
const models = mergeModelAdditions(provider, replacementModels, resolved.additionalModels);
|
||||
if (hasStaticModes) {
|
||||
const defaultModeId = await catalogClient.resolveDefaultModeId?.({
|
||||
config: {
|
||||
provider,
|
||||
cwd: options.scope === "workspace" ? options.cwd : process.cwd(),
|
||||
},
|
||||
});
|
||||
return {
|
||||
models,
|
||||
modes: decorateModes(resolved.definition.modes),
|
||||
defaultModeId,
|
||||
};
|
||||
}
|
||||
const catalog = await catalogClient.fetchCatalog(options);
|
||||
return { models, modes: decorateModes(catalog.modes) };
|
||||
return { ...catalog, models, modes: decorateModes(catalog.modes) };
|
||||
}
|
||||
|
||||
const catalog = await catalogClient.fetchCatalog(options);
|
||||
return {
|
||||
...catalog,
|
||||
models: mergeModels(
|
||||
provider,
|
||||
resolved.profileModels,
|
||||
|
||||
@@ -117,9 +117,11 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
try {
|
||||
const snapshot = manager.getSnapshot("/tmp/project");
|
||||
const claude = snapshot.find((entry) => entry.provider === "claude");
|
||||
const codex = snapshot.find((entry) => entry.provider === "codex");
|
||||
expect(claude?.status).toBe("loading");
|
||||
expect(claude?.label).toBe("Claude");
|
||||
expect(claude?.defaultModeId).toBe("default");
|
||||
expect(claude?.defaultModeId).toBe("auto");
|
||||
expect(codex?.defaultModeId).toBe("auto-review");
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
@@ -220,6 +222,34 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("ready snapshots publish the catalog's capability-aware default mode", async () => {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
extraClients: {
|
||||
codex: createExtraClient("codex", {
|
||||
isAvailable: async () => true,
|
||||
fetchCatalog: async () => ({
|
||||
models: [],
|
||||
modes: [{ id: "default", label: "Default", description: "Ask before running tools" }],
|
||||
defaultModeId: "default",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const entry = await manager.getProvider({
|
||||
cwd: "/tmp/project",
|
||||
provider: "codex",
|
||||
wait: true,
|
||||
});
|
||||
|
||||
expect(entry).toMatchObject({ status: "ready", defaultModeId: "default" });
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit refresh re-probes only the requested warm provider", async () => {
|
||||
const cwd = "/tmp/project";
|
||||
const isAvailableCodex = vi.fn(async () => true);
|
||||
|
||||
@@ -799,6 +799,8 @@ export class ProviderSnapshotManager {
|
||||
|
||||
setEntry({
|
||||
...base,
|
||||
defaultModeId:
|
||||
catalog.defaultModeId === undefined ? definition.defaultModeId : catalog.defaultModeId,
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
models: catalog.models,
|
||||
|
||||
@@ -218,6 +218,37 @@ test("rejects auto mode when Claude Code uses Bedrock", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ env: {}, expected: "auto" },
|
||||
{ env: { CLAUDE_CODE_USE_BEDROCK: "1" }, expected: "default" },
|
||||
{ env: { CLAUDE_CODE_USE_VERTEX: "true" }, expected: "default" },
|
||||
])("defaults to $expected for transport environment $env", async ({ env, expected }) => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtimeSettings: { env },
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.resolveDefaultModeId({
|
||||
config: { provider: "claude", cwd: process.cwd() },
|
||||
}),
|
||||
).resolves.toBe(expected);
|
||||
});
|
||||
|
||||
test("launch environment participates in the Claude default mode", async () => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtimeSettings: { env: {} },
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.resolveDefaultModeId({
|
||||
config: { provider: "claude", cwd: process.cwd() },
|
||||
env: { CLAUDE_CODE_USE_BEDROCK: "1" },
|
||||
}),
|
||||
).resolves.toBe("default");
|
||||
});
|
||||
|
||||
test("allows launch env to disable inherited Bedrock transport for auto mode", async () => {
|
||||
const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK;
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
|
||||
@@ -704,8 +704,8 @@ describe("ClaudeAgentSession features", () => {
|
||||
});
|
||||
|
||||
await expect(session.setThinkingOption?.("ultracode")).resolves.toEqual({
|
||||
type: "info",
|
||||
message: "This change applies next turn.",
|
||||
type: "warning",
|
||||
message: "Thinking level applies next turn",
|
||||
});
|
||||
|
||||
await session.close();
|
||||
|
||||
@@ -50,7 +50,7 @@ import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./quer
|
||||
import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { SETTING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
|
||||
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
|
||||
import {
|
||||
isProviderImageMarkdown,
|
||||
materializeProviderImage,
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
type ListImportableSessionsOptions,
|
||||
type McpServerConfig,
|
||||
type ProviderCatalog,
|
||||
type ResolveAgentDefaultModeInput,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { importSessionFromPersistence } from "../../provider-session-import.js";
|
||||
import {
|
||||
@@ -1472,7 +1473,28 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
|
||||
// Claude exposes a global catalog here; cwd/force are intentionally irrelevant.
|
||||
const models = await getClaudeModelsWithSettings(this.logger, this.configDir);
|
||||
return { models, modes: DEFAULT_MODES };
|
||||
const modes = detectIneligibleAutoModeTransport(
|
||||
createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }),
|
||||
)
|
||||
? DEFAULT_MODES.filter((mode) => mode.id !== "auto")
|
||||
: DEFAULT_MODES;
|
||||
return {
|
||||
models,
|
||||
modes,
|
||||
defaultModeId: modes.some((mode) => mode.id === "auto") ? "auto" : "default",
|
||||
};
|
||||
}
|
||||
|
||||
async resolveDefaultModeId({
|
||||
config,
|
||||
env: launchEnv,
|
||||
}: ResolveAgentDefaultModeInput): Promise<string> {
|
||||
const env = createProviderEnv({
|
||||
baseEnv: process.env,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [config.extra?.claude?.env, launchEnv],
|
||||
});
|
||||
return detectIneligibleAutoModeTransport(env) ? "default" : "auto";
|
||||
}
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
@@ -2209,7 +2231,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
this.queryRestartNeeded = true;
|
||||
if (this.activeForegroundTurnId || this.autonomousTurn) {
|
||||
return SETTING_APPLIES_NEXT_TURN_NOTICE;
|
||||
return THINKING_APPLIES_NEXT_TURN_NOTICE;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -347,12 +347,12 @@ describe("Codex app-server provider", () => {
|
||||
const session = createSession({ modeId: "auto", thinkingOptionId: "medium" });
|
||||
|
||||
await expect(session.setMode("full-access")).resolves.toEqual({
|
||||
type: "info",
|
||||
message: "This change applies next turn.",
|
||||
type: "warning",
|
||||
message: "Permission mode applies next turn",
|
||||
});
|
||||
await expect(session.setThinkingOption?.("high")).resolves.toEqual({
|
||||
type: "info",
|
||||
message: "This change applies next turn.",
|
||||
type: "warning",
|
||||
message: "Thinking level applies next turn",
|
||||
});
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
|
||||
@@ -92,7 +92,10 @@ import {
|
||||
resolveBinaryVersion,
|
||||
} from "./diagnostic-utils.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||
import { SETTING_APPLIES_NEXT_TURN_NOTICE } from "../provider-notices.js";
|
||||
import {
|
||||
MODE_APPLIES_NEXT_TURN_NOTICE,
|
||||
THINKING_APPLIES_NEXT_TURN_NOTICE,
|
||||
} from "../provider-notices.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
|
||||
function assertChildWithPipes(
|
||||
@@ -3943,7 +3946,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.currentMode = modeId;
|
||||
this.cachedRuntimeInfo = null;
|
||||
if (this.activeForegroundTurnId) {
|
||||
return SETTING_APPLIES_NEXT_TURN_NOTICE;
|
||||
return MODE_APPLIES_NEXT_TURN_NOTICE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3961,7 +3964,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.refreshResolvedCollaborationMode();
|
||||
this.cachedRuntimeInfo = null;
|
||||
if (this.activeForegroundTurnId) {
|
||||
return SETTING_APPLIES_NEXT_TURN_NOTICE;
|
||||
return THINKING_APPLIES_NEXT_TURN_NOTICE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6376,8 +6379,21 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
|
||||
const models = await this.fetchModelsFromAppServer();
|
||||
return { models, modes: CODEX_MODES };
|
||||
const [models, autoReviewEnabled] = await Promise.all([
|
||||
this.fetchModelsFromAppServer(),
|
||||
this.resolveAutoReviewEnabled(),
|
||||
]);
|
||||
return {
|
||||
models,
|
||||
defaultModeId: autoReviewEnabled ? "auto-review" : DEFAULT_CODEX_MODE_ID,
|
||||
modes: autoReviewEnabled
|
||||
? CODEX_MODES
|
||||
: CODEX_MODES.filter((mode) => mode.id !== "auto-review"),
|
||||
};
|
||||
}
|
||||
|
||||
async resolveDefaultModeId(): Promise<string> {
|
||||
return (await this.resolveAutoReviewEnabled()) ? "auto-review" : DEFAULT_CODEX_MODE_ID;
|
||||
}
|
||||
|
||||
private async fetchModelsFromAppServer(): Promise<AgentModelDefinition[]> {
|
||||
|
||||
@@ -140,6 +140,29 @@ describe("JsonlRpcProcess", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("null timeout waits past short wall-clock limits until the response arrives", async () => {
|
||||
const transport = startProcess();
|
||||
|
||||
try {
|
||||
await expect(
|
||||
transport.request({ type: "echo", value: "slow", delayMs: 80 }, null),
|
||||
).resolves.toMatchObject({ value: "slow" });
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("null timeout still rejects when the process is closed", async () => {
|
||||
const transport = startProcess();
|
||||
await transport.request({ type: "echo", value: "ready" });
|
||||
const request = transport.request({ type: "hang" }, null);
|
||||
|
||||
const rejection = expect(request).rejects.toThrow("JSONL RPC process is closed");
|
||||
await transport.close();
|
||||
|
||||
await rejection;
|
||||
});
|
||||
|
||||
test("rejects pending requests and publishes stderr when the child exits", async () => {
|
||||
const transport = startProcess();
|
||||
const exit = nextExit(transport);
|
||||
|
||||
@@ -4,7 +4,14 @@ import type { Logger } from "pino";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
/** Default wall-clock timeout for control-plane / short RPC calls. */
|
||||
export const JSONL_RPC_DEFAULT_TIMEOUT_MS = 30_000;
|
||||
/**
|
||||
* Pass as `timeoutMs` to wait only for a response, process death, or `close()`.
|
||||
* Use for long-running blocking RPCs (e.g. LLM-backed compact).
|
||||
*/
|
||||
export const JSONL_RPC_NO_TIMEOUT = null;
|
||||
|
||||
const STDERR_BUFFER_LIMIT = 8192;
|
||||
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
|
||||
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
||||
@@ -28,7 +35,7 @@ interface JsonlRpcResponse {
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
timer: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
export interface JsonlRpcExit {
|
||||
@@ -116,7 +123,7 @@ export class JsonlRpcProcess {
|
||||
|
||||
startRequest(
|
||||
command: { type: string; [key: string]: unknown },
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
timeoutMs: number | null = JSONL_RPC_DEFAULT_TIMEOUT_MS,
|
||||
): { id: string; promise: Promise<unknown> } {
|
||||
if (this.disposed) {
|
||||
return {
|
||||
@@ -127,14 +134,14 @@ export class JsonlRpcProcess {
|
||||
const id = `req_${this.nextRequestId}`;
|
||||
this.nextRequestId += 1;
|
||||
const promise = new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const timer = createRequestTimeout(timeoutMs, () => {
|
||||
this.pending.delete(id);
|
||||
reject(
|
||||
new Error(
|
||||
`${this.diagnosticName} request timed out for ${command.type}\n${this.stderrBuffer}`.trim(),
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
});
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.send({ ...command, id });
|
||||
});
|
||||
@@ -143,7 +150,7 @@ export class JsonlRpcProcess {
|
||||
|
||||
request(
|
||||
command: { type: string; [key: string]: unknown },
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
timeoutMs: number | null = JSONL_RPC_DEFAULT_TIMEOUT_MS,
|
||||
): Promise<unknown> {
|
||||
return this.startRequest(command, timeoutMs).promise;
|
||||
}
|
||||
@@ -228,7 +235,9 @@ export class JsonlRpcProcess {
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
}
|
||||
this.pending.delete(response.id);
|
||||
if (!response.success) {
|
||||
pending.reject(
|
||||
@@ -247,9 +256,25 @@ export class JsonlRpcProcess {
|
||||
}
|
||||
this.disposed = true;
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
}
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a request timeout, or return null when the call should wait
|
||||
* indefinitely for a response, process exit, or close().
|
||||
*/
|
||||
function createRequestTimeout(
|
||||
timeoutMs: number | null,
|
||||
onTimeout: () => void,
|
||||
): NodeJS.Timeout | null {
|
||||
if (timeoutMs == null || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
return setTimeout(onTimeout, timeoutMs);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,13 @@ describe("OMP agent client and session", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves max as the selected thinking option", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start({ thinkingOptionId: "max" });
|
||||
|
||||
expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "max"]));
|
||||
});
|
||||
|
||||
test("streams a prompt through completion", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
@@ -225,7 +232,10 @@ describe("OMP agent client and session", () => {
|
||||
expect.objectContaining({ name: "review", kind: "skill" }),
|
||||
]),
|
||||
);
|
||||
await expect(omp.setMode("ask")).resolves.toEqual(expect.objectContaining({ type: "info" }));
|
||||
await expect(omp.setMode("ask")).resolves.toEqual({
|
||||
type: "warning",
|
||||
message: "Start a new OMP session to change approval mode",
|
||||
});
|
||||
});
|
||||
|
||||
test("rewinds natively, interrupts, and shuts down", async () => {
|
||||
|
||||
@@ -136,7 +136,8 @@ const OMP_THINKING_OPTIONS: ReadonlyArray<{
|
||||
{ id: "low", label: "Low", description: "Faster reasoning" },
|
||||
{ id: "medium", label: "Medium", description: "Balanced reasoning", isDefault: true },
|
||||
{ id: "high", label: "High", description: "Deeper reasoning" },
|
||||
{ id: "xhigh", label: "XHigh", description: "Maximum reasoning" },
|
||||
{ id: "xhigh", label: "XHigh", description: "Extra-high reasoning" },
|
||||
{ id: "max", label: "Max", description: "Maximum reasoning" },
|
||||
] as const;
|
||||
|
||||
export interface OmpAgentClientOptions {
|
||||
@@ -269,7 +270,8 @@ function isOmpThinkingLevel(value: string | null | undefined): value is OmpThink
|
||||
value === "low" ||
|
||||
value === "medium" ||
|
||||
value === "high" ||
|
||||
value === "xhigh"
|
||||
value === "xhigh" ||
|
||||
value === "max"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1082,9 +1084,8 @@ export class OmpAgentSession implements AgentSession {
|
||||
throw new Error(`Invalid OMP mode '${modeId}'`);
|
||||
}
|
||||
return {
|
||||
type: "info",
|
||||
message:
|
||||
"OMP approval mode is set when the agent launches. Start a new OMP session to use a different mode.",
|
||||
type: "warning",
|
||||
message: "Start a new OMP session to change approval mode",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const OmpThinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]);
|
||||
export const OmpThinkingLevelSchema = z.enum([
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
]);
|
||||
|
||||
export const OmpImageContentSchema = z
|
||||
.object({ type: z.literal("image"), data: z.string(), mimeType: z.string() })
|
||||
|
||||
@@ -37,6 +37,28 @@ describe("opencode agent commands E2E", () => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("listing commands resumes an idle-collected agent", async () => {
|
||||
const agent = await ctx.client.createAgent({
|
||||
...getFullAccessConfig("opencode"),
|
||||
cwd: "/tmp",
|
||||
title: "Collected OpenCode Commands Test Agent",
|
||||
});
|
||||
|
||||
const collection = await ctx.daemon.daemon.agentManager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
expect(collection.failures).toEqual([]);
|
||||
expect(collection.collected.map((entry) => entry.agentId)).toContain(agent.id);
|
||||
expect(ctx.daemon.daemon.agentManager.getAgent(agent.id)).toBeNull();
|
||||
|
||||
const result = await ctx.client.listCommands({ agentId: agent.id });
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.commands.length).toBeGreaterThan(0);
|
||||
expect(ctx.daemon.daemon.agentManager.getAgent(agent.id)?.id).toBe(agent.id);
|
||||
}, 60_000);
|
||||
|
||||
test("sendMessage executes a slash command without arguments", async () => {
|
||||
const agent = await ctx.client.createAgent({
|
||||
...getFullAccessConfig("opencode"),
|
||||
|
||||
@@ -194,7 +194,8 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
test("creates a session with valid id and provider", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
runtime.enqueueClient(new TestOpenCodeClient());
|
||||
const openCode = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
@@ -206,9 +207,49 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
expect(session.provider).toBe("opencode");
|
||||
|
||||
await session.close();
|
||||
expect(openCode.calls.sessionAbort).toEqual([{ sessionID: "session-1", directory: cwd }]);
|
||||
expect(openCode.calls.sessionUpdate).toEqual([]);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
test("archives and unarchives the durable native session through client hooks", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const archiveClient = new TestOpenCodeClient();
|
||||
const unarchiveClient = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(archiveClient);
|
||||
runtime.enqueueClient(unarchiveClient);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const handle = {
|
||||
provider: "opencode" as const,
|
||||
sessionId: "session-1",
|
||||
metadata: { cwd },
|
||||
};
|
||||
|
||||
await client.archiveNativeSession(handle);
|
||||
await client.unarchiveNativeSession(handle);
|
||||
|
||||
expect(archiveClient.calls.sessionUpdate).toEqual([
|
||||
{
|
||||
sessionID: "session-1",
|
||||
directory: cwd,
|
||||
time: { archived: expect.any(Number) },
|
||||
},
|
||||
]);
|
||||
expect(unarchiveClient.calls.sessionUpdate).toEqual([
|
||||
{
|
||||
sessionID: "session-1",
|
||||
directory: cwd,
|
||||
time: { archived: null },
|
||||
},
|
||||
]);
|
||||
expect(runtime.acquisitions.every((acquisition) => acquisition.releaseCount === 1)).toBe(true);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("single turn completes with streaming deltas", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
@@ -713,65 +754,6 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
});
|
||||
|
||||
describe("OpenCode adapter context-window normalization", () => {
|
||||
test("close reconciliation aborts then archives upstream session", async () => {
|
||||
const abort = vi.fn().mockResolvedValue({ data: true, error: undefined });
|
||||
const update = vi.fn().mockResolvedValue({
|
||||
data: { id: "session-1", time: { archived: Date.now() } },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
await __openCodeInternals.reconcileOpenCodeSessionClose({
|
||||
client: {
|
||||
session: {
|
||||
abort,
|
||||
update,
|
||||
},
|
||||
} as never,
|
||||
sessionId: "session-1",
|
||||
directory: "/tmp/project",
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(abort).toHaveBeenCalledWith({
|
||||
sessionID: "session-1",
|
||||
directory: "/tmp/project",
|
||||
});
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
sessionID: "session-1",
|
||||
directory: "/tmp/project",
|
||||
time: {
|
||||
archived: expect.any(Number),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("close reconciliation still archives when abort returns an error", async () => {
|
||||
const abort = vi.fn().mockResolvedValue({
|
||||
data: undefined,
|
||||
error: { data: {}, errors: [], success: false },
|
||||
});
|
||||
const update = vi.fn().mockResolvedValue({
|
||||
data: { id: "session-1", time: { archived: Date.now() } },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
await __openCodeInternals.reconcileOpenCodeSessionClose({
|
||||
client: {
|
||||
session: {
|
||||
abort,
|
||||
update,
|
||||
},
|
||||
} as never,
|
||||
sessionId: "session-1",
|
||||
directory: "/tmp/project",
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(abort).toHaveBeenCalledTimes(1);
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("builds OpenCode file parts for image prompt blocks", () => {
|
||||
expect(
|
||||
__openCodeInternals.buildOpenCodePromptParts([
|
||||
@@ -2345,6 +2327,7 @@ describe("OpenCode persisted sessions", () => {
|
||||
describe("OpenCode provider subagent contract", () => {
|
||||
async function createAdoptedChildSession(): Promise<{
|
||||
readonly runtime: TestOpenCodeHarness;
|
||||
readonly provider: OpenCodeAgentClient;
|
||||
readonly parent: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>>;
|
||||
readonly child: Awaited<ReturnType<OpenCodeAgentClient["resumeSession"]>>;
|
||||
readonly childClient: TestOpenCodeClient;
|
||||
@@ -2387,9 +2370,36 @@ describe("OpenCode provider subagent contract", () => {
|
||||
undefined,
|
||||
{ env: { PASEO_AGENT_ID: "child-agent" } },
|
||||
);
|
||||
return { runtime, parent, child, childClient };
|
||||
return { runtime, provider: client, parent, child, childClient };
|
||||
}
|
||||
|
||||
test("archives an adopted child on the parent's registered OpenCode server", async () => {
|
||||
const { runtime, provider, parent, child } = await createAdoptedChildSession();
|
||||
const archiveClient = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(archiveClient);
|
||||
|
||||
await provider.archiveNativeSession({
|
||||
provider: "opencode",
|
||||
sessionId: "ses_child_external",
|
||||
metadata: { cwd: "/workspace/repo" },
|
||||
});
|
||||
|
||||
expect(archiveClient.calls.sessionUpdate).toEqual([
|
||||
{
|
||||
sessionID: "ses_child_external",
|
||||
directory: "/workspace/repo",
|
||||
time: { archived: expect.any(Number) },
|
||||
},
|
||||
]);
|
||||
expect(runtime.acquisitions.at(-1)).toEqual({
|
||||
kind: "existing",
|
||||
url: runtime.server.url,
|
||||
releaseCount: 1,
|
||||
});
|
||||
await child.close();
|
||||
await parent.close();
|
||||
});
|
||||
|
||||
test("resumes an adopted child on the parent's registered OpenCode server", async () => {
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const parentClient = new TestOpenCodeClient();
|
||||
@@ -3033,11 +3043,11 @@ describe("OpenCode provider subagent contract", () => {
|
||||
event: {
|
||||
type: "timeline",
|
||||
id: "ses_child_with_history",
|
||||
item: {
|
||||
item: expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: "Persisted child result.",
|
||||
messageId: "msg_child_history",
|
||||
},
|
||||
}),
|
||||
timestamp: "1970-01-01T00:00:02.000Z",
|
||||
},
|
||||
}),
|
||||
@@ -3111,11 +3121,11 @@ describe("OpenCode provider subagent contract", () => {
|
||||
event: expect.objectContaining({
|
||||
type: "timeline",
|
||||
id: "ses_child_hydrating",
|
||||
item: {
|
||||
item: expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: "Hydration did not lose this.",
|
||||
messageId: "msg_child_hydrating",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -431,7 +431,7 @@ function isOpenCodeNotFoundError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
async function reconcileOpenCodeSessionClose(params: {
|
||||
async function abortOpenCodeSession(params: {
|
||||
client: Pick<OpencodeClient, "session">;
|
||||
sessionId: string;
|
||||
directory: string;
|
||||
@@ -462,31 +462,6 @@ async function reconcileOpenCodeSessionClose(params: {
|
||||
"Failed to abort OpenCode session during close",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.session.update({
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
time: { archived: Date.now() },
|
||||
});
|
||||
if (response.error && !isOpenCodeNotFoundError(response.error)) {
|
||||
logger.warn(
|
||||
{
|
||||
sessionId,
|
||||
error: toDiagnosticErrorMessage(response.error),
|
||||
},
|
||||
"Failed to archive OpenCode session during close",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
sessionId,
|
||||
error: toDiagnosticErrorMessage(error),
|
||||
},
|
||||
"Failed to archive OpenCode session during close",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenCodeHeadersTimeoutFailure(error: unknown): boolean {
|
||||
@@ -1254,7 +1229,6 @@ export const __openCodeInternals = {
|
||||
hasNormalizedOpenCodeUsage,
|
||||
mergeOpenCodeStepFinishUsage,
|
||||
parseOpenCodeModelLookupKey,
|
||||
reconcileOpenCodeSessionClose,
|
||||
resolveOpenCodeModelLookupKeyFromAssistantMessage,
|
||||
resolveOpenCodeSelectedModelContextWindow,
|
||||
isSelectableOpenCodeAgent,
|
||||
@@ -1353,7 +1327,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
url,
|
||||
);
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
await acquisition.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1407,7 +1381,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
registeredAcquisition !== null,
|
||||
);
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
await acquisition.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1439,7 +1413,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
]);
|
||||
return { models, modes };
|
||||
} finally {
|
||||
acquisition.release();
|
||||
await acquisition.release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1455,7 +1429,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
try {
|
||||
return await listOpenCodeCommandsFromSdk(client, openCodeConfig.cwd);
|
||||
} finally {
|
||||
acquisition.release();
|
||||
await acquisition.release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1476,7 +1450,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
try {
|
||||
return await collectOpenCodeImportableSessionsFromSdk(client, options);
|
||||
} finally {
|
||||
acquisition.release();
|
||||
await acquisition.release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1512,7 +1486,57 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
acquisition.release();
|
||||
await acquisition.release();
|
||||
}
|
||||
}
|
||||
|
||||
async archiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
|
||||
await this.setNativeSessionArchived(handle, Date.now());
|
||||
}
|
||||
|
||||
async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
|
||||
await this.setNativeSessionArchived(handle, null);
|
||||
}
|
||||
|
||||
private async setNativeSessionArchived(
|
||||
handle: AgentPersistenceHandle,
|
||||
archivedAt: number | null,
|
||||
): Promise<void> {
|
||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
if (!metadata.cwd) {
|
||||
throw new Error("OpenCode native archive update requires the original working directory");
|
||||
}
|
||||
|
||||
const registeredServerUrl = getOpenCodeChildSessionServerUrl(handle.sessionId);
|
||||
const acquisition =
|
||||
(registeredServerUrl ? this.serverManager.acquireExisting(registeredServerUrl) : null) ??
|
||||
(await this.serverManager.acquireCurrent());
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: acquisition.server.url,
|
||||
directory: metadata.cwd,
|
||||
});
|
||||
try {
|
||||
// OpenCode accepts null to clear the archive timestamp, but this SDK
|
||||
// release's generated request type still exposes only number.
|
||||
const updateSession = client.session.update.bind(client.session) as (parameters: {
|
||||
sessionID: string;
|
||||
directory?: string;
|
||||
time?: { archived?: number | null };
|
||||
}) => ReturnType<typeof client.session.update>;
|
||||
const response = readOpenCodeRecord(
|
||||
await updateSession({
|
||||
sessionID: handle.sessionId,
|
||||
directory: metadata.cwd,
|
||||
time: { archived: archivedAt },
|
||||
}),
|
||||
);
|
||||
if (response?.error) {
|
||||
throw new Error(
|
||||
`Failed to ${archivedAt === null ? "unarchive" : "archive"} OpenCode session: ${toDiagnosticErrorMessage(response.error)}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await acquisition.release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2897,7 +2921,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private childHydrationCompleted = false;
|
||||
private readonly unrelatedSessionIds = new Set<string>();
|
||||
private selectedModelContextWindowMaxTokens: number | undefined;
|
||||
private releaseServer: (() => void) | null;
|
||||
private releaseServer: (() => Promise<void>) | null;
|
||||
private eventStreamAbortController: AbortController | null = null;
|
||||
private eventStreamReady: Deferred<void> | null = null;
|
||||
private eventStreamTask: Promise<void> | null = null;
|
||||
@@ -2911,7 +2935,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
sessionId: string,
|
||||
logger: Logger,
|
||||
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
|
||||
releaseServer?: () => void,
|
||||
releaseServer?: () => Promise<void>,
|
||||
persistSession = true,
|
||||
private readonly agentId?: string,
|
||||
private readonly serverUrl?: string,
|
||||
@@ -3891,7 +3915,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.eventStreamReady = null;
|
||||
this.eventStreamTask = null;
|
||||
this.subscribers.clear();
|
||||
await reconcileOpenCodeSessionClose({
|
||||
await abortOpenCodeSession({
|
||||
client: this.client,
|
||||
sessionId: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
@@ -3900,7 +3924,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
await this.deleteProviderSessionIfEphemeral();
|
||||
this.activeForegroundTurnId = null;
|
||||
} finally {
|
||||
this.releaseServer?.();
|
||||
await this.releaseServer?.();
|
||||
this.releaseServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,10 @@ describe("OpenCodeServerManager generations", () => {
|
||||
expect(newAcquisition.server.url).toBe("http://127.0.0.1:4102");
|
||||
expect(runtime.terminatedPorts).toEqual([]);
|
||||
|
||||
newAcquisition.release();
|
||||
oldAcquisition.release();
|
||||
await newAcquisition.release();
|
||||
await oldAcquisition.release();
|
||||
|
||||
expect(runtime.terminatedPorts).toEqual([4101]);
|
||||
expect(runtime.terminatedPorts).toEqual([4102, 4101]);
|
||||
});
|
||||
|
||||
test("new acquisitions after rotation use the new server", async () => {
|
||||
@@ -48,22 +48,22 @@ describe("OpenCodeServerManager generations", () => {
|
||||
|
||||
const oldAcquisition = await manager.acquireCurrent();
|
||||
const rotatedAcquisition = await manager.acquireNew();
|
||||
rotatedAcquisition.release();
|
||||
|
||||
const nextAcquisition = await manager.acquireCurrent();
|
||||
|
||||
expect(nextAcquisition.server.url).toBe("http://127.0.0.1:4202");
|
||||
expect(runtime.terminatedPorts).toEqual([]);
|
||||
|
||||
nextAcquisition.release();
|
||||
oldAcquisition.release();
|
||||
await rotatedAcquisition.release();
|
||||
expect(runtime.terminatedPorts).toEqual([]);
|
||||
await nextAcquisition.release();
|
||||
await oldAcquisition.release();
|
||||
});
|
||||
|
||||
test("concurrent new-server acquisitions share one fresh generation", async () => {
|
||||
const { manager, runtime } = createTestManager([4251, 4252, 4253]);
|
||||
|
||||
const initialAcquisition = await manager.acquireCurrent();
|
||||
initialAcquisition.release();
|
||||
await initialAcquisition.release();
|
||||
|
||||
const [modelsAcquisition, modesAcquisition] = await Promise.all([
|
||||
manager.acquireNew(),
|
||||
@@ -74,8 +74,8 @@ describe("OpenCodeServerManager generations", () => {
|
||||
expect(modesAcquisition.server.url).toBe("http://127.0.0.1:4252");
|
||||
expect(runtime.launchedPorts).toEqual([4251, 4252]);
|
||||
|
||||
modesAcquisition.release();
|
||||
modelsAcquisition.release();
|
||||
await modesAcquisition.release();
|
||||
await modelsAcquisition.release();
|
||||
});
|
||||
|
||||
test("release is idempotent", async () => {
|
||||
@@ -83,12 +83,12 @@ describe("OpenCodeServerManager generations", () => {
|
||||
|
||||
const oldAcquisition = await manager.acquireCurrent();
|
||||
const newAcquisition = await manager.acquireNew();
|
||||
newAcquisition.release();
|
||||
await newAcquisition.release();
|
||||
|
||||
oldAcquisition.release();
|
||||
oldAcquisition.release();
|
||||
await oldAcquisition.release();
|
||||
await oldAcquisition.release();
|
||||
|
||||
expect(runtime.terminatedPorts).toEqual([4301]);
|
||||
expect(runtime.terminatedPorts).toEqual([4302, 4301]);
|
||||
});
|
||||
|
||||
test("shutdown kills current and retired servers", async () => {
|
||||
@@ -152,16 +152,16 @@ describe("OpenCodeServerManager generations", () => {
|
||||
const dedicatedStart = manager.acquireDedicated({ TEST_ENV: "custom" });
|
||||
await runtime.settle();
|
||||
|
||||
currentAcquisition.release();
|
||||
expect(runtime.terminatedPorts).toEqual([]);
|
||||
await currentAcquisition.release();
|
||||
expect(runtime.terminatedPorts).toEqual([4473]);
|
||||
|
||||
runtime.processForPort(4474).announceListening();
|
||||
const dedicatedAcquisition = await dedicatedStart;
|
||||
|
||||
expect(dedicatedAcquisition.server.url).toBe("http://127.0.0.1:4474");
|
||||
|
||||
dedicatedAcquisition.release();
|
||||
expect(runtime.terminatedPorts).toEqual([4474]);
|
||||
await dedicatedAcquisition.release();
|
||||
expect(runtime.terminatedPorts).toEqual([4473, 4474]);
|
||||
});
|
||||
|
||||
test("acquireExisting keeps a retired dedicated server alive until every reference releases", async () => {
|
||||
@@ -172,10 +172,10 @@ describe("OpenCodeServerManager generations", () => {
|
||||
|
||||
expect(existingAcquisition?.server.url).toBe("http://127.0.0.1:4475");
|
||||
|
||||
dedicatedAcquisition.release();
|
||||
await dedicatedAcquisition.release();
|
||||
expect(runtime.terminatedPorts).toEqual([]);
|
||||
|
||||
existingAcquisition?.release();
|
||||
await existingAcquisition?.release();
|
||||
expect(runtime.terminatedPorts).toEqual([4475]);
|
||||
});
|
||||
|
||||
@@ -187,7 +187,7 @@ describe("OpenCodeServerManager generations", () => {
|
||||
|
||||
expect(manager.acquireExisting("http://127.0.0.1:9999")).toBe(null);
|
||||
|
||||
acquisition.release();
|
||||
await acquisition.release();
|
||||
expect(runtime.terminatedPorts).toEqual([4476]);
|
||||
expect(manager.acquireExisting(url)).toBe(null);
|
||||
});
|
||||
@@ -197,12 +197,26 @@ describe("OpenCodeServerManager generations", () => {
|
||||
|
||||
const firstAcquisition = await manager.acquireCurrent();
|
||||
const secondAcquisition = await manager.acquireNew();
|
||||
secondAcquisition.release();
|
||||
await secondAcquisition.release();
|
||||
const thirdAcquisition = await manager.acquireNew();
|
||||
thirdAcquisition.release();
|
||||
firstAcquisition.release();
|
||||
await thirdAcquisition.release();
|
||||
await firstAcquisition.release();
|
||||
|
||||
expect(runtime.terminatedPorts).toEqual([4502, 4501]);
|
||||
expect(runtime.terminatedPorts).toEqual([4502, 4503, 4501]);
|
||||
});
|
||||
|
||||
test("final release detaches the terminating generation before a concurrent acquire", async () => {
|
||||
const { manager, runtime } = createTestManager([4551, 4552]);
|
||||
|
||||
const first = await manager.acquireCurrent();
|
||||
const release = first.release();
|
||||
const next = await manager.acquireCurrent();
|
||||
|
||||
await release;
|
||||
expect(next.server.url).toBe("http://127.0.0.1:4552");
|
||||
expect(runtime.terminatedPorts).toEqual([4551]);
|
||||
|
||||
await next.release();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,7 +272,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
acquisition.release();
|
||||
await acquisition.release();
|
||||
await manager.shutdown();
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
@@ -21,11 +21,10 @@ const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
||||
|
||||
export interface OpenCodeServerAcquisition {
|
||||
server: { port: number; url: string };
|
||||
release: () => void;
|
||||
release: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface OpenCodeServerManagerLike {
|
||||
ensureRunning(): Promise<{ port: number; url: string }>;
|
||||
acquireCurrent(): Promise<OpenCodeServerAcquisition>;
|
||||
acquireNew(): Promise<OpenCodeServerAcquisition>;
|
||||
acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>;
|
||||
@@ -135,12 +134,6 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
process.on("SIGINT", cleanup);
|
||||
}
|
||||
|
||||
async ensureRunning(): Promise<{ port: number; url: string }> {
|
||||
const acquisition = await this.acquireCurrent();
|
||||
acquisition.release();
|
||||
return acquisition.server;
|
||||
}
|
||||
|
||||
async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
|
||||
const server = await this.getCurrentServer();
|
||||
return this.acquireServer(server);
|
||||
@@ -160,7 +153,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
await server.ready;
|
||||
return acquisition;
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
await acquisition.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -188,20 +181,37 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
|
||||
private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition {
|
||||
server.refCount += 1;
|
||||
let released = false;
|
||||
let releasePromise: Promise<void> | null = null;
|
||||
return {
|
||||
server: { port: server.port, url: server.url },
|
||||
release: () => {
|
||||
if (released) {
|
||||
return;
|
||||
release: async () => {
|
||||
if (releasePromise) {
|
||||
return releasePromise;
|
||||
}
|
||||
released = true;
|
||||
server.refCount -= 1;
|
||||
this.cleanupRetiredServers();
|
||||
releasePromise = this.releaseServer(server);
|
||||
return releasePromise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async releaseServer(server: OpenCodeServerGeneration): Promise<void> {
|
||||
server.refCount = Math.max(0, server.refCount - 1);
|
||||
if (server.refCount > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentServer === server) {
|
||||
this.currentServer = null;
|
||||
server.retired = true;
|
||||
}
|
||||
if (!server.retired) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.retiredServers.delete(server);
|
||||
await this.killServer(server);
|
||||
}
|
||||
|
||||
private async getNewServer(): Promise<OpenCodeServerGeneration> {
|
||||
if (this.newServerPromise) {
|
||||
return this.newServerPromise;
|
||||
@@ -261,14 +271,14 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
existing.retired = true;
|
||||
this.retiredServers.add(existing);
|
||||
this.currentServer = null;
|
||||
this.cleanupRetiredServers();
|
||||
await this.cleanupRetiredServers();
|
||||
}
|
||||
if (this.startPromise) {
|
||||
const pending = await this.startPromise;
|
||||
pending.retired = true;
|
||||
this.retiredServers.add(pending);
|
||||
this.currentServer = null;
|
||||
this.cleanupRetiredServers();
|
||||
await this.cleanupRetiredServers();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,13 +427,15 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
this.retiredServers.clear();
|
||||
}
|
||||
|
||||
private cleanupRetiredServers(): void {
|
||||
private async cleanupRetiredServers(): Promise<void> {
|
||||
const cleanup: Promise<void>[] = [];
|
||||
for (const server of Array.from(this.retiredServers)) {
|
||||
if (server.refCount === 0) {
|
||||
this.retiredServers.delete(server);
|
||||
void this.killServer(server);
|
||||
cleanup.push(this.killServer(server));
|
||||
}
|
||||
}
|
||||
await Promise.all(cleanup);
|
||||
}
|
||||
|
||||
private async killServer(server: OpenCodeServerGeneration): Promise<void> {
|
||||
|
||||
@@ -10,12 +10,6 @@ export interface TestOpenCodeServerAcquisition {
|
||||
export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
readonly acquisitions: TestOpenCodeServerAcquisition[] = [];
|
||||
readonly server = { port: 1234, url: "http://127.0.0.1:1234" };
|
||||
ensureRunningCount = 0;
|
||||
|
||||
async ensureRunning(): Promise<{ port: number; url: string }> {
|
||||
this.ensureRunningCount += 1;
|
||||
return this.server;
|
||||
}
|
||||
|
||||
async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
|
||||
return this.recordAcquisition({ kind: "current" });
|
||||
@@ -47,7 +41,7 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
this.acquisitions.push(acquisition);
|
||||
return {
|
||||
server: this.server,
|
||||
release: () => {
|
||||
release: async () => {
|
||||
acquisition.released = true;
|
||||
},
|
||||
};
|
||||
|
||||
+1
-5
@@ -53,16 +53,12 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
|
||||
this.acquisitions.push(acquisition);
|
||||
return {
|
||||
server: this.server,
|
||||
release: () => {
|
||||
release: async () => {
|
||||
acquisition.releaseCount += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async ensureRunning(): Promise<{ port: number; url: string }> {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
readonly createClient = (options: { baseUrl: string; directory: string }): OpencodeClient => {
|
||||
this.clientCreations.push(options);
|
||||
const client = this.clients.shift() ?? new TestOpenCodeClient();
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { PiCliRuntime } from "./cli-runtime.js";
|
||||
import type { PiRuntimeLaunch } from "./runtime.js";
|
||||
@@ -47,10 +47,7 @@ function createRuntime(
|
||||
});
|
||||
}
|
||||
|
||||
function replyToCommands(
|
||||
child: PiChild,
|
||||
handler: (command: Record<string, unknown>) => unknown,
|
||||
): void {
|
||||
function onPiCommand(child: PiChild, handler: (command: Record<string, unknown>) => void): void {
|
||||
let buffer = "";
|
||||
child.stdin.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
@@ -59,34 +56,68 @@ function replyToCommands(
|
||||
if (newlineIndex === -1) break;
|
||||
const line = buffer.slice(0, newlineIndex);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
const command = JSON.parse(line) as Record<string, unknown>;
|
||||
try {
|
||||
const result = handler(command);
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
data: result,
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: false,
|
||||
error: message,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
handler(JSON.parse(line) as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function replyToCommands(
|
||||
child: PiChild,
|
||||
handler: (command: Record<string, unknown>) => unknown,
|
||||
): void {
|
||||
onPiCommand(child, (command) => {
|
||||
try {
|
||||
const result = handler(command);
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
data: result,
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: false,
|
||||
error: message,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function capturePendingCommand(child: PiChild, type: string): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve) => {
|
||||
onPiCommand(child, (command) => {
|
||||
if (command.type === type) {
|
||||
resolve(command);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function writePiResponse(
|
||||
child: PiChild,
|
||||
command: Record<string, unknown>,
|
||||
data: unknown = {},
|
||||
): void {
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
data,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PiCliRuntime", () => {
|
||||
test("starts pi in rpc mode and resolves command responses", async () => {
|
||||
const child = createPiChild();
|
||||
@@ -282,6 +313,51 @@ describe("PiCliRuntime", () => {
|
||||
await rejection;
|
||||
});
|
||||
|
||||
test("compact waits beyond the default control-plane timeout for a late response", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = createPiChild();
|
||||
const pendingCompact = capturePendingCommand(child, "compact");
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
try {
|
||||
const compactPromise = session.compact("focus on tests");
|
||||
const compactCommand = await pendingCompact;
|
||||
await vi.advanceTimersByTimeAsync(35_000);
|
||||
|
||||
expect(compactCommand).toMatchObject({
|
||||
type: "compact",
|
||||
customInstructions: "focus on tests",
|
||||
id: expect.any(String),
|
||||
});
|
||||
|
||||
writePiResponse(child, compactCommand, {
|
||||
summary: "done",
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 120_000,
|
||||
estimatedTokensAfter: 20_000,
|
||||
});
|
||||
|
||||
await expect(compactPromise).resolves.toBeUndefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("compact without a wall-clock timeout rejects when the session closes", async () => {
|
||||
const child = createPiChild();
|
||||
const pendingCompact = capturePendingCommand(child, "compact");
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
const compactPromise = session.compact();
|
||||
await pendingCompact;
|
||||
|
||||
const rejection = expect(compactPromise).rejects.toThrow("Pi RPC session is closed");
|
||||
await session.close();
|
||||
|
||||
await rejection;
|
||||
});
|
||||
|
||||
test("disposes the Pi process", async () => {
|
||||
const child = createPiChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js";
|
||||
import {
|
||||
JSONL_RPC_NO_TIMEOUT,
|
||||
JsonlRpcProcess,
|
||||
type JsonlRpcLaunch,
|
||||
} from "../jsonl-rpc-process.js";
|
||||
import {
|
||||
buildPiLaunch,
|
||||
type PiRuntime,
|
||||
@@ -26,6 +30,14 @@ const DEFAULT_PI_COMMAND: [string, ...string[]] = [
|
||||
];
|
||||
const DEFAULT_COMMANDS_RPC_NAME = "get_commands";
|
||||
|
||||
/**
|
||||
* Pi RPC timeout policy:
|
||||
* - Control-plane / accept-and-stream (`prompt`, `get_state`, `abort`, …): default 30s
|
||||
* - Long-running blocking LLM jobs (`compact`): no wall-clock timeout — complete on
|
||||
* response, process death, or session close (`JsonlRpcProcess.failAll` / `close`).
|
||||
*/
|
||||
const PI_COMPACT_REQUEST_TIMEOUT_MS = JSONL_RPC_NO_TIMEOUT;
|
||||
|
||||
export interface PiCliRuntimeOptions {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
@@ -112,10 +124,16 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
}
|
||||
|
||||
async compact(customInstructions?: string): Promise<void> {
|
||||
await this.request({
|
||||
type: "compact",
|
||||
...(customInstructions ? { customInstructions } : {}),
|
||||
});
|
||||
// Compact is a blocking LLM summarization job; Pi only returns the RPC
|
||||
// response after the summary is written. A control-plane 30s timeout falsely
|
||||
// fails long sessions while the real compact continues (issue #1946).
|
||||
await this.request(
|
||||
{
|
||||
type: "compact",
|
||||
...(customInstructions ? { customInstructions } : {}),
|
||||
},
|
||||
PI_COMPACT_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
async setAutoCompaction(enabled: boolean): Promise<void> {
|
||||
@@ -135,7 +153,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
return data.messages ?? [];
|
||||
}
|
||||
|
||||
async getAvailableModels(timeoutMs?: number): Promise<PiModel[]> {
|
||||
async getAvailableModels(timeoutMs?: number | null): Promise<PiModel[]> {
|
||||
const data = (await this.request({ type: "get_available_models" }, timeoutMs)) as {
|
||||
models?: PiModel[];
|
||||
};
|
||||
@@ -208,7 +226,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
await this.process.close(new Error("Pi RPC session is closed"));
|
||||
}
|
||||
|
||||
request(command: PiRpcCommand, timeoutMs?: number): Promise<unknown> {
|
||||
request(command: PiRpcCommand, timeoutMs?: number | null): Promise<unknown> {
|
||||
return this.process.request(command, timeoutMs);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,12 +51,15 @@ export interface PiRuntimeSession {
|
||||
abort(): Promise<void>;
|
||||
getState(): Promise<PiSessionState>;
|
||||
getMessages(): Promise<PiAgentMessage[]>;
|
||||
getAvailableModels(timeoutMs?: number): Promise<PiModel[]>;
|
||||
getAvailableModels(timeoutMs?: number | null): Promise<PiModel[]>;
|
||||
setModel(provider: string, modelId: string): Promise<PiModel>;
|
||||
setThinkingLevel(level: string): Promise<void>;
|
||||
getSessionStats(): Promise<PiSessionStats>;
|
||||
getCommands(): Promise<PiRpcSlashCommand[]>;
|
||||
request(command: { type: string; [key: string]: unknown }, timeoutMs?: number): Promise<unknown>;
|
||||
request(
|
||||
command: { type: string; [key: string]: unknown },
|
||||
timeoutMs?: number | null,
|
||||
): Promise<unknown>;
|
||||
sendRawFrame(frame: object & { type: string }): void;
|
||||
respondToExtensionUiRequest(
|
||||
id: string,
|
||||
|
||||
@@ -227,7 +227,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
return this.messages;
|
||||
}
|
||||
|
||||
async getAvailableModels(_timeoutMs?: number): Promise<PiModel[]> {
|
||||
async getAvailableModels(_timeoutMs?: number | null): Promise<PiModel[]> {
|
||||
return this.models;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -486,8 +486,6 @@ describe("archiveIfSafe", () => {
|
||||
}),
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: "ws-auto-archive" },
|
||||
repoRoot: "/tmp/repo",
|
||||
paseoWorktreesBaseRoot: undefined,
|
||||
requestId: "auto-archive-on-merge",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -138,8 +138,6 @@ export async function archiveIfSafe(input: {
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot: ownership.repoRoot ?? null,
|
||||
paseoWorktreesBaseRoot: options.paseoWorktreesBaseRoot,
|
||||
requestId: "auto-archive-on-merge",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -18,7 +19,9 @@ describe("bootstrap provider availability", () => {
|
||||
process.env.PATH = originalEnv.PATH;
|
||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
tempRoots
|
||||
.splice(0)
|
||||
.map((root) => rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,12 +29,13 @@ describe("bootstrap provider availability", () => {
|
||||
const { createPaseoDaemon } = await import("./bootstrap.js");
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-"));
|
||||
tempRoots.push(root);
|
||||
const binDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-bin-"));
|
||||
tempRoots.push(binDir);
|
||||
process.env.PATH = binDir;
|
||||
if (process.platform === "win32") {
|
||||
process.env.PATHEXT = ".CMD";
|
||||
}
|
||||
const gitPath = execFileSync(process.platform === "win32" ? "where" : "which", ["git"], {
|
||||
encoding: "utf8",
|
||||
})
|
||||
.split(/\r?\n/)[0]
|
||||
.trim();
|
||||
process.env.PATH = path.dirname(gitPath);
|
||||
expect(execFileSync("git", ["--version"], { encoding: "utf8" })).toMatch(/git version/i);
|
||||
const paseoHome = path.join(root, ".paseo");
|
||||
const staticDir = path.join(root, "static");
|
||||
const agentStoragePath = path.join(paseoHome, "agents");
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { fanOutReconciledWorkspaceUpdates } from "./bootstrap.js";
|
||||
|
||||
test("reconciliation emits workspace updates when observer sync fails", async () => {
|
||||
const emittedWorkspaceIds: string[][] = [];
|
||||
const syncFailure = new Error("workspace observer unavailable");
|
||||
|
||||
await fanOutReconciledWorkspaceUpdates({
|
||||
sessions: [
|
||||
{
|
||||
syncWorkspaceGitObserversForExternalWorkspaceIds: async () => {
|
||||
throw syncFailure;
|
||||
},
|
||||
emitWorkspaceUpdatesForExternalWorkspaceIds: async (workspaceIds) => {
|
||||
emittedWorkspaceIds.push(Array.from(workspaceIds));
|
||||
},
|
||||
},
|
||||
],
|
||||
workspaceIds: ["ws-reclassified"],
|
||||
logger: { warn: () => {} },
|
||||
});
|
||||
|
||||
expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]);
|
||||
});
|
||||
|
||||
test("reconciliation isolates workspace update failures between sessions", async () => {
|
||||
const emittedWorkspaceIds: string[][] = [];
|
||||
const warnings: unknown[] = [];
|
||||
|
||||
await fanOutReconciledWorkspaceUpdates({
|
||||
sessions: [
|
||||
{
|
||||
syncWorkspaceGitObserversForExternalWorkspaceIds: async () => {},
|
||||
emitWorkspaceUpdatesForExternalWorkspaceIds: async () => {
|
||||
throw new Error("session closed");
|
||||
},
|
||||
},
|
||||
{
|
||||
syncWorkspaceGitObserversForExternalWorkspaceIds: async () => {},
|
||||
emitWorkspaceUpdatesForExternalWorkspaceIds: async (workspaceIds) => {
|
||||
emittedWorkspaceIds.push(Array.from(workspaceIds));
|
||||
},
|
||||
},
|
||||
],
|
||||
workspaceIds: ["ws-reclassified"],
|
||||
logger: {
|
||||
warn: (context) => {
|
||||
warnings.push(context);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]);
|
||||
expect(warnings).toHaveLength(1);
|
||||
});
|
||||
@@ -89,12 +89,42 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null {
|
||||
return listenTarget.path;
|
||||
}
|
||||
|
||||
export async function fanOutReconciledWorkspaceUpdates(input: {
|
||||
sessions: Iterable<{
|
||||
syncWorkspaceGitObserversForExternalWorkspaceIds(workspaceIds: Iterable<string>): Promise<void>;
|
||||
emitWorkspaceUpdatesForExternalWorkspaceIds(
|
||||
workspaceIds: Iterable<string>,
|
||||
options: { skipReconcile: boolean },
|
||||
): Promise<void>;
|
||||
}>;
|
||||
workspaceIds: readonly string[];
|
||||
logger: Pick<Logger, "warn">;
|
||||
}): Promise<void> {
|
||||
await Promise.all(
|
||||
Array.from(input.sessions, async (session) => {
|
||||
try {
|
||||
await session.syncWorkspaceGitObserversForExternalWorkspaceIds(input.workspaceIds);
|
||||
} catch (error) {
|
||||
input.logger.warn(
|
||||
{ err: error },
|
||||
"Failed to sync workspace Git observers after reconciliation",
|
||||
);
|
||||
}
|
||||
try {
|
||||
await session.emitWorkspaceUpdatesForExternalWorkspaceIds(input.workspaceIds, {
|
||||
skipReconcile: true,
|
||||
});
|
||||
} catch (error) {
|
||||
input.logger.warn({ err: error }, "Failed to emit workspace updates after reconciliation");
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||
import { createGitHubService } from "../services/github-service.js";
|
||||
import {
|
||||
createPaseoWorktree as createRegisteredPaseoWorktree,
|
||||
createLocalCheckoutWorkspace,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
|
||||
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
||||
@@ -172,9 +202,23 @@ import {
|
||||
createAgentCommand,
|
||||
type CreateAgentCommandDependencies,
|
||||
} from "./agent/create-agent/create.js";
|
||||
import { archiveAgentCommand } from "./agent/lifecycle-command.js";
|
||||
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
|
||||
import {
|
||||
HubRelationshipController,
|
||||
type HubRelationshipClock,
|
||||
type HubRelationshipRetryPolicy,
|
||||
} from "./hub/relationship-controller.js";
|
||||
import {
|
||||
DirectHubRelationshipRemote,
|
||||
type HubRelationshipRemote,
|
||||
} from "./hub/relationship-remote.js";
|
||||
import { DaemonExecutions } from "./hub/daemon-executions.js";
|
||||
|
||||
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
|
||||
const REDACTED_LOG_VALUE = "[redacted]";
|
||||
const IDLE_AGENT_RUNTIME_TTL_MS = 2 * 60 * 1000;
|
||||
const IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS = 15 * 1000;
|
||||
const DOWNLOAD_OPEN_FLAGS =
|
||||
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
|
||||
|
||||
@@ -404,6 +448,13 @@ export interface PaseoDaemon {
|
||||
getListenTarget(): ListenTarget | null;
|
||||
}
|
||||
|
||||
export interface PaseoDaemonDependencies {
|
||||
hubRelationshipRemote?: HubRelationshipRemote;
|
||||
hubRelationshipClock?: HubRelationshipClock;
|
||||
hubRelationshipRetryPolicy?: HubRelationshipRetryPolicy;
|
||||
createHubDaemonId?: () => string;
|
||||
}
|
||||
|
||||
function createBootstrapManagedProcessRegistry(
|
||||
config: Pick<PaseoDaemonConfig, "paseoHome" | "managedProcesses">,
|
||||
logger: Logger,
|
||||
@@ -481,6 +532,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
|
||||
export async function createPaseoDaemon(
|
||||
config: PaseoDaemonConfig,
|
||||
rootLogger: Logger,
|
||||
dependencies: PaseoDaemonDependencies = {},
|
||||
): Promise<PaseoDaemon> {
|
||||
const logger = rootLogger.child({ module: "bootstrap" });
|
||||
const bootstrapStart = performance.now();
|
||||
@@ -547,7 +599,7 @@ export async function createPaseoDaemon(
|
||||
serviceProxy,
|
||||
onChange: createScriptStatusEmitter({
|
||||
sessions: () =>
|
||||
wsServer?.listActiveSessions().map((session) => ({
|
||||
wsServer?.listTrustedSessions().map((session) => ({
|
||||
emit: (message) => session.emitServerMessage(message),
|
||||
})) ?? [],
|
||||
serviceProxy,
|
||||
@@ -744,6 +796,12 @@ export async function createPaseoDaemon(
|
||||
forgeOverrides: { github },
|
||||
},
|
||||
});
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
});
|
||||
const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" });
|
||||
const providerSnapshotManager = new ProviderSnapshotManager({
|
||||
logger: providerSnapshotLogger,
|
||||
@@ -788,21 +846,19 @@ export async function createPaseoDaemon(
|
||||
workspaceRegistry,
|
||||
logger,
|
||||
workspaceGitService,
|
||||
onProjectUpdate: (update) => wsServer?.publishProjectUpdate(update),
|
||||
onWorkspacesChanged: async (workspaceIds) => {
|
||||
await fanOutReconciledWorkspaceUpdates({
|
||||
sessions: wsServer?.listTrustedSessions() ?? [],
|
||||
workspaceIds,
|
||||
logger,
|
||||
});
|
||||
},
|
||||
});
|
||||
await workspaceReconciliation.start();
|
||||
void workspaceReconciliation.runOnce().catch((error) => {
|
||||
logger.warn({ err: error }, "Initial workspace reconciliation failed");
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await workspaceReconciliation.runOnce();
|
||||
logger.info(
|
||||
{
|
||||
elapsed: elapsed(),
|
||||
changeCount: result.changesApplied.length,
|
||||
},
|
||||
"Workspace registries reconciled",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Background workspace reconciliation failed");
|
||||
}
|
||||
})();
|
||||
await chatService.initialize();
|
||||
logger.info({ elapsed: elapsed() }, "Chat service initialized");
|
||||
const checkoutDiffManager = new CheckoutDiffManager({
|
||||
@@ -811,7 +867,7 @@ export async function createPaseoDaemon(
|
||||
workspaceGitService,
|
||||
});
|
||||
const archiveWorkspaceRecordExternal = async (workspaceId: string) => {
|
||||
const sessions = wsServer?.listActiveSessions() ?? [];
|
||||
const sessions = wsServer?.listTrustedSessions() ?? [];
|
||||
if (sessions.length > 0) {
|
||||
await Promise.all(
|
||||
sessions.map((session) => session.archiveWorkspaceRecordForExternalMutation(workspaceId)),
|
||||
@@ -833,9 +889,9 @@ export async function createPaseoDaemon(
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
): Promise<string> => {
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd, title: resolveFirstAgentPromptTitle(firstAgentContext) },
|
||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
||||
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||
cwd,
|
||||
resolveFirstAgentPromptTitle(firstAgentContext),
|
||||
);
|
||||
if (firstAgentContext) {
|
||||
workspaceAutoName.scheduleForDirectory({
|
||||
@@ -854,24 +910,27 @@ export async function createPaseoDaemon(
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.cwd,
|
||||
kind: workspace.kind,
|
||||
worktreeRoot: workspace.worktreeRoot,
|
||||
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: workspace.mainRepoRoot,
|
||||
}));
|
||||
};
|
||||
const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => {
|
||||
const workspaceIdList = Array.from(workspaceIds);
|
||||
for (const session of wsServer?.listActiveSessions() ?? []) {
|
||||
for (const session of wsServer?.listTrustedSessions() ?? []) {
|
||||
session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt);
|
||||
}
|
||||
};
|
||||
const clearWorkspaceArchivingExternal = (workspaceIds: Iterable<string>) => {
|
||||
const workspaceIdList = Array.from(workspaceIds);
|
||||
for (const session of wsServer?.listActiveSessions() ?? []) {
|
||||
for (const session of wsServer?.listTrustedSessions() ?? []) {
|
||||
session.clearWorkspaceArchivingForExternalMutation(workspaceIdList);
|
||||
}
|
||||
};
|
||||
const emitWorkspaceUpdatesExternal = async (workspaceIds: Iterable<string>) => {
|
||||
const workspaceIdList = Array.from(workspaceIds);
|
||||
await Promise.all(
|
||||
(wsServer?.listActiveSessions() ?? []).map((session) =>
|
||||
(wsServer?.listTrustedSessions() ?? []).map((session) =>
|
||||
session.emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIdList),
|
||||
),
|
||||
);
|
||||
@@ -942,15 +1001,14 @@ export async function createPaseoDaemon(
|
||||
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
||||
}
|
||||
: {}),
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
workspaceProvisioning,
|
||||
});
|
||||
},
|
||||
warmWorkspaceGitData: async (workspace) => {
|
||||
await Promise.all(
|
||||
wsServer
|
||||
?.listActiveSessions()
|
||||
?.listTrustedSessions()
|
||||
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
|
||||
);
|
||||
},
|
||||
@@ -989,6 +1047,57 @@ export async function createPaseoDaemon(
|
||||
};
|
||||
const createAgent = (input: Parameters<typeof createAgentCommand>[1]) =>
|
||||
createAgentCommand(createAgentCommandDependencies, input);
|
||||
const hubAgentLifecycle = new CreateAgentLifecycleDispatch({
|
||||
paseoHome: config.paseoHome,
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
agentManager,
|
||||
agentStorage,
|
||||
github,
|
||||
workspaceGitService,
|
||||
createPaseoWorktreeWorkflow: createPaseoWorktreeForTools,
|
||||
archiveAgentForClose: (agentId) =>
|
||||
archiveAgentCommand({ agentManager, agentStorage, logger }, agentId),
|
||||
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
|
||||
listActiveWorkspaces: listActiveWorkspacesExternal,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
emit: emitExternalSessionMessage,
|
||||
emitAgentRemove: () => undefined,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
killTerminalsForWorkspace: (workspaceId) =>
|
||||
killTerminalsForWorkspace({ terminalManager, sessionLogger: logger }, workspaceId),
|
||||
logger,
|
||||
});
|
||||
const hubRelationships = new HubRelationshipController({
|
||||
paseoHome: config.paseoHome,
|
||||
serverId,
|
||||
daemonPublicKey: daemonKeyPair.publicKeyB64,
|
||||
logger,
|
||||
remote: dependencies.hubRelationshipRemote ?? new DirectHubRelationshipRemote(),
|
||||
clock: dependencies.hubRelationshipClock,
|
||||
retryPolicy: dependencies.hubRelationshipRetryPolicy,
|
||||
createDaemonId: dependencies.createHubDaemonId,
|
||||
attachSocket: async (socket, options) => {
|
||||
if (!wsServer) throw new Error("WebSocket server is not running");
|
||||
await wsServer.attachHubSocket(socket, options);
|
||||
},
|
||||
createExecutionAgents: (daemonId) =>
|
||||
new DaemonExecutions({
|
||||
daemonId,
|
||||
agentManager,
|
||||
agentStorage,
|
||||
createAgent,
|
||||
registerAutoArchive: ({ agentId, createdWorktree }) =>
|
||||
hubAgentLifecycle.registerAutoArchiveIfRequested({
|
||||
autoArchive: true,
|
||||
agentId,
|
||||
createdWorktree,
|
||||
}),
|
||||
cleanupFailedCreate: (input) =>
|
||||
hubAgentLifecycle.cleanupCreatedWorktreeAfterFailedAgentCreate(input),
|
||||
}),
|
||||
});
|
||||
|
||||
const loopService = new LoopService({
|
||||
paseoHome: config.paseoHome,
|
||||
@@ -1003,9 +1112,9 @@ export async function createPaseoDaemon(
|
||||
cwd: string;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}) => {
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd: input.cwd, title: resolveFirstAgentPromptTitle(input.firstAgentContext) },
|
||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
||||
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||
input.cwd,
|
||||
resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
);
|
||||
workspaceAutoName.scheduleForDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
@@ -1026,7 +1135,7 @@ export async function createPaseoDaemon(
|
||||
await emitWorkspaceUpdatesExternal([result.workspace.workspaceId]);
|
||||
return result;
|
||||
};
|
||||
const archiveScheduleWorkspaceExternal = async (workspaceId: string, repoRoot: string) => {
|
||||
const archiveScheduleWorkspaceExternal = async (workspaceId: string) => {
|
||||
await archiveByScope(
|
||||
{
|
||||
paseoHome: config.paseoHome,
|
||||
@@ -1053,8 +1162,6 @@ export async function createPaseoDaemon(
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot,
|
||||
paseoWorktreesBaseRoot: config.worktreesRoot,
|
||||
requestId: "schedule-run-finish",
|
||||
},
|
||||
);
|
||||
@@ -1065,11 +1172,44 @@ export async function createPaseoDaemon(
|
||||
agentManager,
|
||||
agentStorage,
|
||||
createAgent,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspaceExternal,
|
||||
createDirectoryWorkspace: createScheduleLocalWorkspaceExternal,
|
||||
createPaseoWorktreeWorkspace: createSchedulePaseoWorktreeExternal,
|
||||
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
||||
});
|
||||
await scheduleService.start();
|
||||
let inFlightIdleAgentCollection: Promise<void> | null = null;
|
||||
const collectIdleAgentRuntimes = async () => {
|
||||
const protectedAgentIds = await scheduleService.listActiveAgentTargetIds();
|
||||
const cutoff = new Date(Date.now() - IDLE_AGENT_RUNTIME_TTL_MS);
|
||||
const result = await agentManager.collectIdleAgents({ cutoff, protectedAgentIds });
|
||||
for (const collected of result.collected) {
|
||||
logger.info(collected, "Collected idle agent runtime");
|
||||
}
|
||||
for (const failure of result.failures) {
|
||||
const { error, ...context } = failure;
|
||||
logger.warn({ ...context, err: error }, "Failed to collect idle agent runtime");
|
||||
}
|
||||
};
|
||||
const runIdleAgentCollection = () => {
|
||||
if (inFlightIdleAgentCollection) {
|
||||
return;
|
||||
}
|
||||
const collection = collectIdleAgentRuntimes()
|
||||
.catch((error) => {
|
||||
logger.warn({ err: error }, "Idle agent runtime sweep failed");
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightIdleAgentCollection === collection) {
|
||||
inFlightIdleAgentCollection = null;
|
||||
}
|
||||
});
|
||||
inFlightIdleAgentCollection = collection;
|
||||
};
|
||||
const idleAgentCollectionTimer = setInterval(
|
||||
runIdleAgentCollection,
|
||||
IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS,
|
||||
);
|
||||
idleAgentCollectionTimer.unref();
|
||||
agentManager.setAgentArchivedCallback(async (agentId) => {
|
||||
try {
|
||||
await scheduleService.completeForAgent(agentId);
|
||||
@@ -1105,6 +1245,16 @@ export async function createPaseoDaemon(
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
createDirectoryWorkspace: async (cwd, title, projectId) => {
|
||||
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||
cwd,
|
||||
title,
|
||||
projectId,
|
||||
);
|
||||
await emitWorkspaceUpdatesExternal([workspace.workspaceId]);
|
||||
return workspace;
|
||||
},
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
ensureWorkspaceForCreate: createAgentCommandDependencies.ensureWorkspaceForCreate,
|
||||
@@ -1383,7 +1533,9 @@ export async function createPaseoDaemon(
|
||||
},
|
||||
serviceProxyPublicBaseUrl,
|
||||
browserToolsBroker,
|
||||
hubRelationships,
|
||||
);
|
||||
await hubRelationships.start();
|
||||
|
||||
if (relayEnabled) {
|
||||
const offer = await createConnectionOfferV2({
|
||||
@@ -1444,7 +1596,11 @@ export async function createPaseoDaemon(
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
await hubRelationships.stop();
|
||||
workspaceReconciliation.dispose();
|
||||
scriptHealthMonitor.stop();
|
||||
clearInterval(idleAgentCollectionTimer);
|
||||
await inFlightIdleAgentCollection;
|
||||
// Freeze both ingress and registration before taking the agent closure snapshot.
|
||||
wsServer?.prepareForShutdown();
|
||||
agentManager.prepareForShutdown();
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import { DaemonClient } from "./test-utils/index.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
|
||||
// The daemon-level workspace contract that `paseo run` depends on: each
|
||||
// local-backed createWorkspace for a cwd mints a fresh, distinct workspace,
|
||||
@@ -28,9 +29,10 @@ async function mintLocalWorkspace(client: DaemonClient, cwd: string): Promise<st
|
||||
return result.workspace.id;
|
||||
}
|
||||
|
||||
test("daemon mints a distinct local workspace per run and stamps agents by id", async () => {
|
||||
test("daemon resolves human and managed CLI workspace ownership", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-cwd-"));
|
||||
const otherCwd = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-other-cwd-"));
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.82",
|
||||
@@ -56,14 +58,13 @@ test("daemon mints a distinct local workspace per run and stamps agents by id",
|
||||
const fetchedFirst = await client.fetchAgent({ agentId: firstAgent.id });
|
||||
expect(fetchedFirst?.agent.workspaceId).toBe(firstWorkspaceId);
|
||||
|
||||
// A second bare run in the SAME cwd mints a DISTINCT workspace; each run
|
||||
// owns its own workspace rather than reattaching to the first.
|
||||
const secondWorkspaceId = await mintLocalWorkspace(client, cwd);
|
||||
// A second bare run mints a distinct workspace with its own authoritative cwd.
|
||||
const secondWorkspaceId = await mintLocalWorkspace(client, otherCwd);
|
||||
expect(secondWorkspaceId).not.toBe(firstWorkspaceId);
|
||||
|
||||
const secondAgent = await client.createAgent({
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd,
|
||||
cwd: otherCwd,
|
||||
workspaceId: secondWorkspaceId,
|
||||
title: "Second run agent",
|
||||
});
|
||||
@@ -80,15 +81,48 @@ test("daemon mints a distinct local workspace per run and stamps agents by id",
|
||||
const idsBeforeAttach = await workspaceIds(client);
|
||||
const attachedAgent = await client.createAgent({
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd,
|
||||
cwd: path.join(otherCwd, "stale-client-directory"),
|
||||
workspaceId: firstWorkspaceId,
|
||||
title: "Attached agent",
|
||||
});
|
||||
expect(attachedAgent.workspaceId).toBe(firstWorkspaceId);
|
||||
expect(attachedAgent.cwd).toBe(cwd);
|
||||
expect(await workspaceIds(client)).toEqual(idsBeforeAttach);
|
||||
|
||||
await expect(
|
||||
client.createAgent({
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd,
|
||||
workspaceId: "wks_missing",
|
||||
title: "Missing workspace agent",
|
||||
}),
|
||||
).rejects.toThrow("Workspace wks_missing not found");
|
||||
|
||||
const sameWorkspaceChild = await client.createAgent({
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd: otherCwd,
|
||||
callerAgentId: firstAgent.id,
|
||||
title: "Same workspace child",
|
||||
});
|
||||
expect(sameWorkspaceChild.workspaceId).toBe(firstWorkspaceId);
|
||||
expect(sameWorkspaceChild.cwd).toBe(firstAgent.cwd);
|
||||
expect(sameWorkspaceChild.labels[PARENT_AGENT_ID_LABEL]).toBe(firstAgent.id);
|
||||
expect(await workspaceIds(client)).toEqual(idsBeforeAttach);
|
||||
|
||||
const crossWorkspaceChild = await client.createAgent({
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd,
|
||||
workspaceId: secondWorkspaceId,
|
||||
callerAgentId: firstAgent.id,
|
||||
title: "Cross workspace child",
|
||||
});
|
||||
expect(crossWorkspaceChild.workspaceId).toBe(secondWorkspaceId);
|
||||
expect(crossWorkspaceChild.cwd).toBe(otherCwd);
|
||||
expect(crossWorkspaceChild.labels[PARENT_AGENT_ID_LABEL]).toBe(firstAgent.id);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
rmSync(otherCwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
@@ -1006,6 +1006,7 @@ test("receives server_info on websocket connect", async () => {
|
||||
expect(serverInfo).not.toBeNull();
|
||||
expect(serverInfo?.serverId.length).toBeGreaterThan(0);
|
||||
expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true);
|
||||
expect(serverInfo?.features?.hubRelationship).toBe(true);
|
||||
expect(serverInfo?.features?.commitsList).toBe(true);
|
||||
expect(serverInfo?.desktopManaged).toBe(false);
|
||||
expect(serverInfo?.features?.daemonSelfUpdate).toBe(true);
|
||||
|
||||
@@ -62,10 +62,10 @@ test("project.add creates a project without creating a workspace", async () => {
|
||||
expect(added.project).not.toBeNull();
|
||||
const project = added.project!;
|
||||
expect(project).toMatchObject({
|
||||
projectId: repoRoot,
|
||||
projectRootPath: repoRoot,
|
||||
projectKind: "git",
|
||||
});
|
||||
expect(project.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
|
||||
const workspaces = await client.fetchWorkspaces({
|
||||
filter: { projectId: project.projectId },
|
||||
@@ -73,7 +73,6 @@ test("project.add creates a project without creating a workspace", async () => {
|
||||
expect(workspaces.entries).toEqual([]);
|
||||
expect(workspaces.emptyProjects).toEqual([
|
||||
expect.objectContaining({
|
||||
projectId: repoRoot,
|
||||
projectRootPath: repoRoot,
|
||||
projectKind: "git",
|
||||
}),
|
||||
|
||||
+3
-7
@@ -29,7 +29,7 @@ afterEach(async () => {
|
||||
cleanupPaths.clear();
|
||||
});
|
||||
|
||||
test("openProject reclassifies an existing directory workspace into its parent git project", async () => {
|
||||
test("openProject preserves a worktree's exact-root project without rehoming it", async () => {
|
||||
const previousSupervised = process.env.PASEO_SUPERVISED;
|
||||
process.env.PASEO_SUPERVISED = "0";
|
||||
try {
|
||||
@@ -113,17 +113,13 @@ test("openProject reclassifies an existing directory workspace into its parent g
|
||||
const persistedWorkspaces = await readRegistry<PersistedWorkspaceRecord>(workspacesPath);
|
||||
|
||||
expect(response.error).toBeNull();
|
||||
expect(response.workspace?.projectId).toBe(repoRoot);
|
||||
expect(response.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(response.workspace?.projectId).toBe(worktreeRoot);
|
||||
expect(persistedProjects.find((project) => project.projectId === repoRoot)?.rootPath).toBe(
|
||||
repoRoot,
|
||||
);
|
||||
expect(
|
||||
persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.projectId,
|
||||
).toBe(repoRoot);
|
||||
expect(
|
||||
persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.kind,
|
||||
).toBe("worktree");
|
||||
).toBe(worktreeRoot);
|
||||
} finally {
|
||||
process.env.PASEO_SUPERVISED = previousSupervised;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
|
||||
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||
import { DaemonClient, type DaemonEvent } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { type PersistedProjectRecord } from "../workspace-registry.js";
|
||||
|
||||
const cleanupPaths = new Set<string>();
|
||||
const cleanupDaemons = new Set<TestPaseoDaemon>();
|
||||
const cleanupClients = new Set<DaemonClient>();
|
||||
const cleanupListeners = new Set<() => void>();
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
type ProjectUpdatePayload = Extract<DaemonEvent, { type: "project.update" }>["payload"];
|
||||
|
||||
function waitForProjectUpdate(
|
||||
client: DaemonClient,
|
||||
predicate: (payload: ProjectUpdatePayload) => boolean,
|
||||
): Promise<ProjectUpdatePayload> {
|
||||
return new Promise((resolve) => {
|
||||
const unsubscribe = client.on("project.update", (message) => {
|
||||
if (!predicate(message.payload)) return;
|
||||
cleanupListeners.delete(unsubscribe);
|
||||
unsubscribe();
|
||||
resolve(message.payload);
|
||||
});
|
||||
cleanupListeners.add(unsubscribe);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const unsubscribe of cleanupListeners) unsubscribe();
|
||||
cleanupListeners.clear();
|
||||
await Promise.all(Array.from(cleanupClients, (client) => client.close().catch(() => undefined)));
|
||||
cleanupClients.clear();
|
||||
await Promise.all(Array.from(cleanupDaemons, (daemon) => daemon.close().catch(() => undefined)));
|
||||
cleanupDaemons.clear();
|
||||
await Promise.all(
|
||||
Array.from(cleanupPaths, (target) => rm(target, { recursive: true, force: true })),
|
||||
);
|
||||
cleanupPaths.clear();
|
||||
});
|
||||
|
||||
test("an empty project becomes Git without changing its identity or creating a workspace", async () => {
|
||||
const projectRoot = realpathSync(
|
||||
mkdtempSync(path.join(os.tmpdir(), "paseo-project-becomes-git-")),
|
||||
);
|
||||
const paseoHomeRoot = realpathSync(
|
||||
mkdtempSync(path.join(os.tmpdir(), "paseo-project-becomes-git-home-")),
|
||||
);
|
||||
cleanupPaths.add(projectRoot);
|
||||
cleanupPaths.add(paseoHomeRoot);
|
||||
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
|
||||
cleanupDaemons.add(daemon);
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
cleanupClients.add(client);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "project-becomes-git" } });
|
||||
|
||||
const added = await client.addProject(projectRoot);
|
||||
|
||||
expect(added).toEqual({
|
||||
requestId: expect.any(String),
|
||||
project: {
|
||||
projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
|
||||
projectDisplayName: path.basename(projectRoot),
|
||||
projectCustomName: null,
|
||||
projectRootPath: projectRoot,
|
||||
projectKind: "non_git",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
const project = added.project!;
|
||||
const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
|
||||
expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] });
|
||||
|
||||
const gitProjectUpdate = waitForProjectUpdate(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.kind === "upsert" &&
|
||||
payload.project.projectId === project.projectId &&
|
||||
payload.project.projectRootPath === projectRoot &&
|
||||
payload.project.projectKind === "git",
|
||||
);
|
||||
await execFile("git", ["init", "-b", "main"], { cwd: projectRoot });
|
||||
const update = await withTimeout({
|
||||
promise: gitProjectUpdate,
|
||||
timeoutMs: 10_000,
|
||||
label: "project.update after git init",
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
kind: "upsert",
|
||||
project: { ...project, projectKind: "git" },
|
||||
});
|
||||
|
||||
const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
|
||||
expect(afterGitInit).toMatchObject({
|
||||
entries: [],
|
||||
emptyProjects: [{ ...project, projectKind: "git" }],
|
||||
});
|
||||
|
||||
const persistedProjects = JSON.parse(
|
||||
await readFile(path.join(daemon.paseoHome, "projects", "projects.json"), "utf8"),
|
||||
) as PersistedProjectRecord[];
|
||||
expect(persistedProjects).toContainEqual({
|
||||
projectId: project.projectId,
|
||||
rootPath: projectRoot,
|
||||
kind: "git",
|
||||
displayName: project.projectDisplayName,
|
||||
customName: null,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
archivedAt: null,
|
||||
});
|
||||
}, 30_000);
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { HubRelationshipHarness } from "./test-utils/relationship-harness.js";
|
||||
|
||||
let relationship: HubRelationshipHarness | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await relationship?.close();
|
||||
relationship = null;
|
||||
});
|
||||
|
||||
async function launchRelationship(): Promise<HubRelationshipHarness> {
|
||||
const launched = await HubRelationshipHarness.start();
|
||||
await launched.beginConnect().result;
|
||||
launched.connectLatestSocket();
|
||||
relationship = launched;
|
||||
return launched;
|
||||
}
|
||||
|
||||
test("sequential replay after reconstruction keeps one durable owned agent", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const created = await hub.createOwnedConcurrently();
|
||||
|
||||
const reconstructed = await hub.reconstructAndReplay();
|
||||
|
||||
expect(reconstructed.replay.agent.id).toBe(created.first.agentId);
|
||||
expect(reconstructed.replay.agent.status).toBe("closed");
|
||||
expect(reconstructed.durableAgentCount).toBe(1);
|
||||
});
|
||||
|
||||
test("removing a daemon-owned agent removes its execution association", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const created = await hub.createOwnedConcurrently();
|
||||
|
||||
const removed = await hub.removeOwnedAgent(created.first.agentId);
|
||||
|
||||
expect(removed.durableAgentCount).toBe(0);
|
||||
});
|
||||
|
||||
test("a failed Hub create removes its auto-created worktree", async () => {
|
||||
const hub = await launchRelationship();
|
||||
hub.beginOwnedCreate("failed-worktree-create", "failed-worktree-execution", {
|
||||
modeId: "missing-mode",
|
||||
worktree: { mode: "branch-off", newBranch: "failed-hub-create" },
|
||||
});
|
||||
|
||||
const response = await hub.ownedCreateResult("failed-worktree-create");
|
||||
|
||||
expect(response).toMatchObject({
|
||||
type: "hub.execution.agent.create.response",
|
||||
payload: { success: false, executionId: "failed-worktree-execution" },
|
||||
});
|
||||
expect(await hub.listedWorktrees()).toHaveLength(1);
|
||||
expect(await hub.durableOwnedAgentIds()).toEqual([]);
|
||||
});
|
||||
|
||||
test("failed Hub auto-archive creates release their lifecycle subscriptions", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const subscriptionBaseline = hub.agentSubscriptionCount();
|
||||
|
||||
hub.failProviderPromptStart();
|
||||
hub.beginOwnedCreate("failed-prompt-create-1", "failed-prompt-execution-1", {
|
||||
autoArchive: true,
|
||||
worktree: { mode: "branch-off", newBranch: "failed-prompt-1" },
|
||||
});
|
||||
const first = await hub.ownedCreateResult("failed-prompt-create-1");
|
||||
|
||||
expect(first).toMatchObject({
|
||||
type: "hub.execution.agent.create.response",
|
||||
payload: { success: false, executionId: "failed-prompt-execution-1" },
|
||||
});
|
||||
expect(hub.activeOwnedAgentIds()).toEqual([]);
|
||||
expect(await hub.durableOwnedAgentIds()).toEqual([]);
|
||||
expect(await hub.listedWorktrees()).toHaveLength(1);
|
||||
expect(hub.agentSubscriptionCount()).toBe(subscriptionBaseline);
|
||||
|
||||
hub.failProviderPromptStart();
|
||||
hub.beginOwnedCreate("failed-prompt-create-2", "failed-prompt-execution-2", {
|
||||
autoArchive: true,
|
||||
worktree: { mode: "branch-off", newBranch: "failed-prompt-2" },
|
||||
});
|
||||
const second = await hub.ownedCreateResult("failed-prompt-create-2");
|
||||
|
||||
expect(second).toMatchObject({
|
||||
type: "hub.execution.agent.create.response",
|
||||
payload: { success: false, executionId: "failed-prompt-execution-2" },
|
||||
});
|
||||
expect(hub.activeOwnedAgentIds()).toEqual([]);
|
||||
expect(await hub.durableOwnedAgentIds()).toEqual([]);
|
||||
expect(await hub.listedWorktrees()).toHaveLength(1);
|
||||
expect(hub.agentSubscriptionCount()).toBe(subscriptionBaseline);
|
||||
});
|
||||
|
||||
test("failed Hub create cleans durable state when provider close rejects", async () => {
|
||||
const hub = await launchRelationship();
|
||||
hub.failProviderPromptStart();
|
||||
hub.failNextProviderSessionClose();
|
||||
hub.beginOwnedCreate("failed-close-create", "failed-close-execution", {
|
||||
worktree: { mode: "branch-off", newBranch: "failed-close-worktree" },
|
||||
});
|
||||
|
||||
const response = await hub.ownedCreateResult("failed-close-create");
|
||||
|
||||
expect(response).toMatchObject({
|
||||
type: "hub.execution.agent.create.response",
|
||||
payload: { success: false, executionId: "failed-close-execution" },
|
||||
});
|
||||
expect(hub.activeOwnedAgentIds()).toEqual([]);
|
||||
expect(await hub.durableOwnedAgentIds()).toEqual([]);
|
||||
expect(await hub.listedWorktrees()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Hub checkout uses the requested branch ref", async () => {
|
||||
const hub = await launchRelationship();
|
||||
await hub.createBranch("existing-hub-branch");
|
||||
hub.beginOwnedCreate("checkout-create", "checkout-execution", {
|
||||
worktree: { mode: "checkout-branch", branch: "existing-hub-branch" },
|
||||
});
|
||||
|
||||
const response = await hub.ownedCreateResult("checkout-create");
|
||||
|
||||
expect(response).toMatchObject({
|
||||
type: "hub.execution.agent.create.response",
|
||||
payload: { success: true, executionId: "checkout-execution" },
|
||||
});
|
||||
expect(await hub.currentBranch(hub.latestCreatedCwd()!)).toBe("existing-hub-branch");
|
||||
});
|
||||
|
||||
test("failed create never archives a reused worktree", async () => {
|
||||
const hub = await launchRelationship();
|
||||
hub.beginOwnedCreate("original-create", "original-execution", {
|
||||
worktree: { mode: "branch-off", newBranch: "shared-hub-worktree" },
|
||||
});
|
||||
const original = await hub.ownedCreateResult("original-create");
|
||||
const worktreeCwd = original.payload.agent?.cwd;
|
||||
expect(worktreeCwd).toEqual(expect.any(String));
|
||||
await hub.ownedTurnCompletion(original.payload.agentId!);
|
||||
|
||||
const failedPrompt = "Fail the reused worktree create";
|
||||
hub.failProviderPromptStart(failedPrompt);
|
||||
hub.beginOwnedCreate("reused-create", "reused-execution", {
|
||||
autoArchive: true,
|
||||
prompt: failedPrompt,
|
||||
worktree: { mode: "branch-off", newBranch: "shared-hub-worktree" },
|
||||
});
|
||||
const reused = await hub.ownedCreateResult("reused-create");
|
||||
|
||||
expect(reused).toMatchObject({
|
||||
type: "hub.execution.agent.create.response",
|
||||
payload: { success: false, executionId: "reused-execution" },
|
||||
});
|
||||
expect(await hub.worktreeState(worktreeCwd!)).toEqual({ exists: true, listed: true });
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import type {
|
||||
AgentSnapshotPayload,
|
||||
AgentStreamEventPayload,
|
||||
CreateAgentWorktreeTarget,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
import type { AgentManager, AgentManagerEvent, ManagedAgent } from "../agent/agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "../agent/agent-storage.js";
|
||||
import type { LifecycleRegistration } from "../agent/create-agent-lifecycle-dispatch.js";
|
||||
import type { BoundCreateAgentCommand } from "../agent/create-agent/create.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../worktree-session.js";
|
||||
import { buildStoredAgentPayload } from "../agent/agent-projections.js";
|
||||
import { serializeAgentSnapshot, serializeAgentStreamEvent } from "../messages.js";
|
||||
import { daemonExecutionKey, type DaemonAgentOwner } from "../agent/agent-owner.js";
|
||||
|
||||
export interface HubExecutionAgentCreateInput {
|
||||
executionId: string;
|
||||
provider: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
worktree?: CreateAgentWorktreeTarget;
|
||||
autoArchive?: boolean;
|
||||
}
|
||||
|
||||
export interface OwnedAgentSnapshot {
|
||||
executionId: string;
|
||||
agent: AgentSnapshotPayload;
|
||||
}
|
||||
|
||||
export type OwnedAgentEvent =
|
||||
| { type: "update"; executionId: string; agent: AgentSnapshotPayload }
|
||||
| {
|
||||
type: "stream";
|
||||
executionId: string;
|
||||
agentId: string;
|
||||
event: AgentStreamEventPayload;
|
||||
};
|
||||
|
||||
interface DaemonExecutionsOptions {
|
||||
daemonId: string;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
createAgent: BoundCreateAgentCommand;
|
||||
registerAutoArchive?: (input: {
|
||||
agentId: string;
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
||||
}) => LifecycleRegistration;
|
||||
cleanupFailedCreate?: (input: {
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
||||
createdAgentId: string | null;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface HubExecutionAgents {
|
||||
create(input: HubExecutionAgentCreateInput): Promise<OwnedAgentSnapshot>;
|
||||
subscribe(listener: (event: OwnedAgentEvent) => void): () => void;
|
||||
invalidateAuthority(): Promise<void>;
|
||||
}
|
||||
|
||||
export class DaemonExecutions implements HubExecutionAgents {
|
||||
private readonly daemonId: string;
|
||||
private readonly agentManager: AgentManager;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly createAgentCommand: BoundCreateAgentCommand;
|
||||
private readonly pendingCreates = new Map<string, Promise<OwnedAgentSnapshot>>();
|
||||
private authorityGeneration = 0;
|
||||
private authorityActive = true;
|
||||
private readonly registerAutoArchive: (input: {
|
||||
agentId: string;
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
||||
}) => LifecycleRegistration;
|
||||
private readonly cleanupFailedCreate: NonNullable<DaemonExecutionsOptions["cleanupFailedCreate"]>;
|
||||
|
||||
constructor(options: DaemonExecutionsOptions) {
|
||||
this.daemonId = options.daemonId;
|
||||
this.agentManager = options.agentManager;
|
||||
this.agentStorage = options.agentStorage;
|
||||
this.createAgentCommand = options.createAgent;
|
||||
this.registerAutoArchive =
|
||||
options.registerAutoArchive ?? (() => ({ cancel: async () => undefined }));
|
||||
this.cleanupFailedCreate = options.cleanupFailedCreate ?? (async () => undefined);
|
||||
}
|
||||
|
||||
create(input: HubExecutionAgentCreateInput): Promise<OwnedAgentSnapshot> {
|
||||
if (!this.authorityActive) {
|
||||
return Promise.reject(new Error("Hub relationship authority is no longer active"));
|
||||
}
|
||||
const owner = this.owner(input.executionId);
|
||||
const key = daemonExecutionKey(owner);
|
||||
const pending = this.pendingCreates.get(key);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const authorityGeneration = this.authorityGeneration;
|
||||
const create = this.createOrResolve(owner, input, authorityGeneration).finally(() => {
|
||||
if (this.pendingCreates.get(key) === create) {
|
||||
this.pendingCreates.delete(key);
|
||||
}
|
||||
});
|
||||
this.pendingCreates.set(key, create);
|
||||
return create;
|
||||
}
|
||||
|
||||
async invalidateAuthority(): Promise<void> {
|
||||
this.authorityActive = false;
|
||||
this.authorityGeneration++;
|
||||
await Promise.allSettled(this.pendingCreates.values());
|
||||
}
|
||||
|
||||
subscribe(listener: (event: OwnedAgentEvent) => void): () => void {
|
||||
return this.agentManager.subscribe(
|
||||
(event) => {
|
||||
const owned = this.projectEvent(event);
|
||||
if (owned) {
|
||||
listener(owned);
|
||||
}
|
||||
},
|
||||
{ replayState: true },
|
||||
);
|
||||
}
|
||||
|
||||
private async createOrResolve(
|
||||
owner: DaemonAgentOwner,
|
||||
input: HubExecutionAgentCreateInput,
|
||||
authorityGeneration: number,
|
||||
): Promise<OwnedAgentSnapshot> {
|
||||
const existing = await this.agentStorage.findByDaemonExecution(owner);
|
||||
if (existing) {
|
||||
this.requireAuthority(authorityGeneration);
|
||||
return this.resolveRecord(existing);
|
||||
}
|
||||
this.requireAuthority(authorityGeneration);
|
||||
|
||||
let createdWorktree: CreatePaseoWorktreeWorkflowResult | null = null;
|
||||
let createdAgentId: string | null = null;
|
||||
let autoArchiveRegistration: LifecycleRegistration = { cancel: async () => undefined };
|
||||
let result: Awaited<ReturnType<BoundCreateAgentCommand>>;
|
||||
try {
|
||||
result = await this.createAgentCommand({
|
||||
kind: "mcp",
|
||||
provider: input.model ? `${input.provider}/${input.model}` : input.provider,
|
||||
title: input.prompt,
|
||||
initialPrompt: input.prompt,
|
||||
promptFailure: "throw",
|
||||
cwd: input.cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
mode: input.modeId,
|
||||
thinking: input.thinkingOptionId,
|
||||
features: input.featureValues,
|
||||
env: input.env,
|
||||
worktree: toCreateAgentWorktree(input.worktree),
|
||||
background: true,
|
||||
notifyOnFinish: false,
|
||||
owner,
|
||||
onWorktreeCreated: (worktree) => {
|
||||
createdWorktree = worktree;
|
||||
},
|
||||
onCreated: (created) => {
|
||||
createdAgentId = created.agentId;
|
||||
if (input.autoArchive === true) {
|
||||
autoArchiveRegistration = this.registerAutoArchive({
|
||||
...created,
|
||||
createdWorktree: ownedCreatedWorktree(created.createdWorktree),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
this.requireAuthority(authorityGeneration);
|
||||
} catch (error) {
|
||||
try {
|
||||
await autoArchiveRegistration.cancel();
|
||||
if (createdAgentId && this.agentManager.getAgent(createdAgentId)) {
|
||||
try {
|
||||
await this.agentManager.closeAgent(createdAgentId);
|
||||
} finally {
|
||||
await this.agentManager.deleteAgentState(createdAgentId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await this.cleanupFailedCreate({
|
||||
createdWorktree: ownedCreatedWorktree(createdWorktree),
|
||||
createdAgentId: null,
|
||||
});
|
||||
} finally {
|
||||
if (createdAgentId) {
|
||||
await this.agentStorage.remove(createdAgentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
executionId: owner.executionId,
|
||||
agent: serializeAgentSnapshot(result.liveSnapshot),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveRecord(record: StoredAgentRecord): OwnedAgentSnapshot {
|
||||
return this.projectRecord(record);
|
||||
}
|
||||
|
||||
private requireAuthority(authorityGeneration: number): void {
|
||||
if (!this.authorityActive || authorityGeneration !== this.authorityGeneration) {
|
||||
throw new Error("Hub relationship authority ended during agent creation");
|
||||
}
|
||||
}
|
||||
|
||||
private projectRecord(record: StoredAgentRecord): OwnedAgentSnapshot {
|
||||
const owner = this.requireOwner(record);
|
||||
const live = this.agentManager.getAgent(record.id);
|
||||
return {
|
||||
executionId: owner.executionId,
|
||||
agent: live
|
||||
? serializeAgentSnapshot(live)
|
||||
: {
|
||||
...buildStoredAgentPayload(record, this.agentManager.getRegisteredProviderIds()),
|
||||
status: "closed",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private projectEvent(event: AgentManagerEvent): OwnedAgentEvent | null {
|
||||
if (event.type === "agent_state") {
|
||||
return this.projectAgentState(event.agent);
|
||||
}
|
||||
if (event.type !== "agent_stream") {
|
||||
return null;
|
||||
}
|
||||
const agent = this.agentManager.getAgent(event.agentId);
|
||||
if (!this.isOwned(agent)) {
|
||||
return null;
|
||||
}
|
||||
const serialized = serializeAgentStreamEvent(event.event);
|
||||
if (!serialized) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "stream",
|
||||
executionId: agent.owner.executionId,
|
||||
agentId: agent.id,
|
||||
event: serialized,
|
||||
};
|
||||
}
|
||||
|
||||
private projectAgentState(agent: ManagedAgent): OwnedAgentEvent | null {
|
||||
if (!this.isOwned(agent)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "update",
|
||||
executionId: agent.owner.executionId,
|
||||
agent: serializeAgentSnapshot(agent),
|
||||
};
|
||||
}
|
||||
|
||||
private isOwned(agent: ManagedAgent | null): agent is ManagedAgent & { owner: DaemonAgentOwner } {
|
||||
return agent?.owner?.kind === "daemon" && agent.owner.daemonId === this.daemonId;
|
||||
}
|
||||
|
||||
private owner(executionId: string): DaemonAgentOwner {
|
||||
return { kind: "daemon", daemonId: this.daemonId, executionId };
|
||||
}
|
||||
|
||||
private requireOwner(record: StoredAgentRecord): DaemonAgentOwner {
|
||||
const owner = record.owner;
|
||||
if (owner?.kind !== "daemon" || owner.daemonId !== this.daemonId) {
|
||||
throw new Error(`Agent ${record.id} is not owned by daemon ${this.daemonId}`);
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
}
|
||||
|
||||
function ownedCreatedWorktree(
|
||||
worktree: CreatePaseoWorktreeWorkflowResult | null,
|
||||
): CreatePaseoWorktreeWorkflowResult | null {
|
||||
return worktree?.created === true ? worktree : null;
|
||||
}
|
||||
|
||||
function toCreateAgentWorktree(target: CreateAgentWorktreeTarget | undefined) {
|
||||
if (!target) return undefined;
|
||||
if (target.mode === "branch-off") {
|
||||
return {
|
||||
worktreeName: target.newBranch,
|
||||
baseBranch: target.base,
|
||||
action: "branch-off" as const,
|
||||
};
|
||||
}
|
||||
if (target.mode === "checkout-branch") {
|
||||
return { refName: target.branch, action: "checkout" as const };
|
||||
}
|
||||
return { githubPrNumber: target.prNumber, action: "checkout" as const };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type {
|
||||
AgentSnapshotPayload,
|
||||
HubExecutionAgentCreateRequest,
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
import type {
|
||||
HubExecutionAgents,
|
||||
OwnedAgentEvent,
|
||||
OwnedAgentSnapshot,
|
||||
} from "./daemon-executions.js";
|
||||
import { HubExecutionController } from "./execution-controller.js";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve(value: T): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((accept, decline) => {
|
||||
resolve = accept;
|
||||
reject = decline;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
class ControlledHubExecutionAgents implements HubExecutionAgents {
|
||||
private readonly createObserved = deferred<void>();
|
||||
private readonly createGate = deferred<OwnedAgentSnapshot>();
|
||||
|
||||
create(): Promise<OwnedAgentSnapshot> {
|
||||
this.createObserved.resolve();
|
||||
return this.createGate.promise;
|
||||
}
|
||||
|
||||
subscribe(_listener: (event: OwnedAgentEvent) => void): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
async invalidateAuthority(): Promise<void> {}
|
||||
|
||||
async creationStarted(): Promise<void> {
|
||||
await this.createObserved.promise;
|
||||
}
|
||||
|
||||
finishCreate(): void {
|
||||
this.createGate.resolve({
|
||||
executionId: "execution-shutdown",
|
||||
agent: {
|
||||
id: "agent-shutdown",
|
||||
status: "running",
|
||||
} as AgentSnapshotPayload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("HubExecutionController", () => {
|
||||
test("cleanup fences in-flight creates before the dead session can receive a response", async () => {
|
||||
const agents = new ControlledHubExecutionAgents();
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const controller = new HubExecutionController({
|
||||
agents,
|
||||
send: (message) => messages.push(message),
|
||||
});
|
||||
|
||||
const create = controller.createAgent({
|
||||
type: "hub.execution.agent.create.request",
|
||||
requestId: "shutdown-create",
|
||||
executionId: "execution-shutdown",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/paseo",
|
||||
prompt: "sleep 30",
|
||||
} satisfies HubExecutionAgentCreateRequest);
|
||||
await agents.creationStarted();
|
||||
|
||||
const cleanup = controller.cleanup();
|
||||
agents.finishCreate();
|
||||
await Promise.all([create, cleanup]);
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import type {
|
||||
HubExecutionAgentCreateRequest,
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
import type { HubExecutionAgents, OwnedAgentEvent } from "./daemon-executions.js";
|
||||
|
||||
interface HubExecutionControllerOptions {
|
||||
agents: HubExecutionAgents;
|
||||
send: (message: SessionOutboundMessage) => void;
|
||||
}
|
||||
|
||||
export class HubExecutionController {
|
||||
private readonly agents: HubExecutionAgents;
|
||||
private readonly send: (message: SessionOutboundMessage) => void;
|
||||
private readonly unsubscribe: () => void;
|
||||
private readonly pendingCreates = new Set<Promise<void>>();
|
||||
private cleanupPromise: Promise<void> | null = null;
|
||||
private closed = false;
|
||||
|
||||
constructor(options: HubExecutionControllerOptions) {
|
||||
this.agents = options.agents;
|
||||
this.send = options.send;
|
||||
this.unsubscribe = this.agents.subscribe((event) => this.sendOwnedEvent(event));
|
||||
}
|
||||
|
||||
cleanup(): Promise<void> {
|
||||
this.cleanupPromise ??= this.cleanupOnce();
|
||||
return this.cleanupPromise;
|
||||
}
|
||||
|
||||
private async cleanupOnce(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.unsubscribe();
|
||||
await Promise.allSettled(this.pendingCreates);
|
||||
}
|
||||
|
||||
async createAgent(message: HubExecutionAgentCreateRequest): Promise<void> {
|
||||
if (this.closed) return;
|
||||
const create = this.createAgentWithResponse(message);
|
||||
this.pendingCreates.add(create);
|
||||
try {
|
||||
await create;
|
||||
} finally {
|
||||
this.pendingCreates.delete(create);
|
||||
}
|
||||
}
|
||||
|
||||
private async createAgentWithResponse(message: HubExecutionAgentCreateRequest): Promise<void> {
|
||||
try {
|
||||
requireNonBlankHubAgentField("executionId", message.executionId);
|
||||
requireNonBlankHubAgentField("prompt", message.prompt);
|
||||
requireNonBlankHubAgentField("cwd", message.cwd);
|
||||
if (!isAbsolute(message.cwd)) throw new Error("Hub agent cwd must be absolute");
|
||||
const result = await this.agents.create({
|
||||
executionId: message.executionId,
|
||||
provider: message.provider,
|
||||
cwd: message.cwd,
|
||||
workspaceId: message.workspaceId,
|
||||
prompt: message.prompt,
|
||||
model: message.model,
|
||||
modeId: message.modeId,
|
||||
thinkingOptionId: message.thinkingOptionId,
|
||||
featureValues: message.featureValues,
|
||||
env: message.env,
|
||||
worktree: message.worktree,
|
||||
autoArchive: message.autoArchive,
|
||||
});
|
||||
if (this.closed) return;
|
||||
this.send({
|
||||
type: "hub.execution.agent.create.response",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
executionId: message.executionId,
|
||||
agentId: result.agent.id,
|
||||
agent: result.agent,
|
||||
success: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.closed) return;
|
||||
this.send({
|
||||
type: "hub.execution.agent.create.response",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
executionId: message.executionId,
|
||||
agentId: null,
|
||||
agent: null,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sendOwnedEvent(event: OwnedAgentEvent): void {
|
||||
if (this.closed) return;
|
||||
if (event.type === "update") {
|
||||
this.send({
|
||||
type: "hub.execution.agent.update",
|
||||
payload: {
|
||||
executionId: event.executionId,
|
||||
agentId: event.agent.id,
|
||||
agent: event.agent,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.send({
|
||||
type: "hub.execution.agent.stream",
|
||||
payload: {
|
||||
executionId: event.executionId,
|
||||
agentId: event.agentId,
|
||||
event: event.event,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function requireNonBlankHubAgentField(
|
||||
field: "executionId" | "prompt" | "cwd",
|
||||
value: string,
|
||||
): void {
|
||||
if (value.trim().length === 0) {
|
||||
throw new Error(`Hub agent ${field} cannot be blank`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import {
|
||||
HubRelationshipHarness,
|
||||
SetupFailingArchiveWatchFiles,
|
||||
} from "./test-utils/relationship-harness.js";
|
||||
|
||||
let relationship: HubRelationshipHarness | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await relationship?.close();
|
||||
relationship = null;
|
||||
});
|
||||
|
||||
async function launchRelationship(): Promise<HubRelationshipHarness> {
|
||||
const launched = await HubRelationshipHarness.start();
|
||||
await launched.beginConnect().result;
|
||||
launched.connectLatestSocket();
|
||||
relationship = launched;
|
||||
return launched;
|
||||
}
|
||||
|
||||
test("Hub retries one durable daemon execution across concurrency and reconstruction", async () => {
|
||||
const hub = await launchRelationship();
|
||||
|
||||
const created = await hub.createOwnedConcurrently();
|
||||
const update = await hub.ownedUpdate(created.first.agentId);
|
||||
const stream = await hub.ownedStream(created.first.agentId);
|
||||
const reconstructed = await hub.reconstructAndReplay();
|
||||
|
||||
expect(created.duplicate.agentId).toBe(created.first.agentId);
|
||||
expect(update).toMatchObject({
|
||||
executionId: "execution-1",
|
||||
agentId: created.first.agentId,
|
||||
agent: { id: created.first.agentId },
|
||||
});
|
||||
expect(stream).toMatchObject({ executionId: "execution-1", agentId: created.first.agentId });
|
||||
expect(reconstructed.replay.agent.id).toBe(created.first.agentId);
|
||||
expect(reconstructed.durableAgentCount).toBe(1);
|
||||
});
|
||||
|
||||
test("Hub denies trusted steering and browser dispatch", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const localAgentId = await hub.createUnrelatedLocalAgent();
|
||||
|
||||
const steeringDenial = await hub.deniedSteering(localAgentId);
|
||||
const browserDenial = await hub.deniedBrowserDispatch();
|
||||
|
||||
expect(steeringDenial).toEqual({
|
||||
requestId: "denied-steer",
|
||||
requestType: "send_agent_message_request",
|
||||
error: "Session is not authorized for send_agent_message_request",
|
||||
code: "access_denied",
|
||||
});
|
||||
expect(browserDenial).toEqual({
|
||||
requestId: "browser-1",
|
||||
requestType: "browser.automation.execute.response",
|
||||
error: "Session is not authorized for browser.automation.execute.response",
|
||||
code: "access_denied",
|
||||
});
|
||||
expect(hub.observedAgentIds()).not.toContain(localAgentId);
|
||||
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
|
||||
});
|
||||
|
||||
test("Hub sockets reject trusted hello and capabilities", async () => {
|
||||
const hub = await launchRelationship();
|
||||
|
||||
expect(hub.probeTrustedHello()).toBe(4002);
|
||||
});
|
||||
|
||||
test("Hub sockets reject trusted binary frames", async () => {
|
||||
const hub = await launchRelationship();
|
||||
|
||||
expect(hub.probeBinaryFrame()).toBe(4002);
|
||||
});
|
||||
|
||||
test("Hub does not receive trusted broadcasts", async () => {
|
||||
const hub = await launchRelationship();
|
||||
|
||||
const trustedBroadcasts = await hub.trustedBroadcastCount();
|
||||
const trustedStatus = await hub.trustedDaemonStatus();
|
||||
|
||||
expect(trustedBroadcasts).toBe(0);
|
||||
expect(trustedStatus).toMatchObject({ pid: process.pid, relay: { enabled: false } });
|
||||
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
|
||||
});
|
||||
|
||||
test("Hub reconnects without retaining trusted session state", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const created = await hub.createOwnedConcurrently();
|
||||
|
||||
const reconnected = await hub.reconnectAndRetry();
|
||||
|
||||
expect(reconnected).toMatchObject({
|
||||
executionId: "execution-1",
|
||||
agentId: created.first.agentId,
|
||||
});
|
||||
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
|
||||
});
|
||||
|
||||
test("Hub create forwards worktree and auto-archive through the existing create path", async () => {
|
||||
const hub = await launchRelationship();
|
||||
hub.beginOwnedCreate("worktree-create", "execution-worktree", {
|
||||
worktree: { mode: "branch-off", newBranch: "hub-created-worktree", base: "main" },
|
||||
autoArchive: true,
|
||||
prompt: "sleep 30",
|
||||
modeId: "always-ask",
|
||||
});
|
||||
const worktreeCreated = await hub.ownedCreateResult("worktree-create");
|
||||
const worktreeCwd = hub.latestCreatedCwd();
|
||||
const permission = await hub.ownedPermissionRequest(worktreeCreated.payload.agentId!);
|
||||
const duringRun = await hub.worktreeState(worktreeCwd!);
|
||||
const archiveCompletion = hub.waitForOwnedArchiveCompletion(worktreeCreated.payload.agentId!);
|
||||
await hub.allowOwnedPermission(worktreeCreated.payload.agentId!, permission.id);
|
||||
await hub.ownedTurnCompletion(worktreeCreated.payload.agentId!);
|
||||
const archive = await archiveCompletion;
|
||||
const afterArchive = await hub.worktreeState(worktreeCwd!);
|
||||
|
||||
expect(worktreeCreated).toMatchObject({
|
||||
type: "hub.execution.agent.create.response",
|
||||
payload: { success: true, agent: { cwd: worktreeCwd } },
|
||||
});
|
||||
expect(worktreeCwd).not.toBe(hub.repoRoot());
|
||||
expect(duringRun).toEqual({ exists: true, listed: true });
|
||||
expect(afterArchive).toEqual({ exists: false, listed: false });
|
||||
expect(archive).toEqual({
|
||||
agentArchivedAt: expect.any(String),
|
||||
workspaceArchivedAt: expect.any(String),
|
||||
});
|
||||
}, 20_000);
|
||||
|
||||
test("archive observation closes its first watcher when the second watcher cannot start", async () => {
|
||||
const watchFiles = new SetupFailingArchiveWatchFiles(2);
|
||||
const hub = await HubRelationshipHarness.start(watchFiles);
|
||||
relationship = hub;
|
||||
await hub.beginConnect().result;
|
||||
hub.connectLatestSocket();
|
||||
hub.beginOwnedCreate("watch-setup-create", "watch-setup-execution");
|
||||
const created = await hub.ownedCreateResult("watch-setup-create");
|
||||
|
||||
await expect(hub.waitForOwnedArchiveCompletion(created.payload.agentId!)).rejects.toThrow(
|
||||
"Cannot watch",
|
||||
);
|
||||
|
||||
expect(watchFiles.activeDirectories()).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,806 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { platform } from "node:os";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { HubRelationshipHarness } from "./test-utils/relationship-harness.js";
|
||||
|
||||
async function captureUnhandledRejections(action: () => Promise<void>): Promise<unknown[]> {
|
||||
const rejections: unknown[] = [];
|
||||
const capture = (reason: unknown) => rejections.push(reason);
|
||||
process.on("unhandledRejection", capture);
|
||||
try {
|
||||
await action();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
} finally {
|
||||
process.off("unhandledRejection", capture);
|
||||
}
|
||||
return rejections;
|
||||
}
|
||||
|
||||
describe("Hub relationship", () => {
|
||||
let relationship: HubRelationshipHarness | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await relationship?.close();
|
||||
relationship = null;
|
||||
});
|
||||
|
||||
test("the CLI connects, reports status, and disconnects through the daemon", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
const connected = await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
|
||||
const status = await relationship.status();
|
||||
const enrollment = relationship.enrollmentAttempts()[0];
|
||||
const secret = relationship.relationshipFile()?.credential?.secret;
|
||||
const disconnected = await relationship.disconnect();
|
||||
|
||||
expect(connected.state).toBe("connecting");
|
||||
expect(status.state).toBe("connected");
|
||||
expect(relationship.loggableValues(status)).not.toContain(secret);
|
||||
expect(relationship.loggableValues(status)).not.toContain(enrollment.credentialVerifier);
|
||||
expect(relationship.loggableValues(status)).not.toContain(enrollment.token);
|
||||
expect(relationship.loggableValues(status)).not.toContain(enrollment.idempotencyKey);
|
||||
expect(disconnected.state).toBe("not_connected");
|
||||
}, 30_000);
|
||||
|
||||
test("Hub URLs cannot persist embedded credentials", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
|
||||
await expect(
|
||||
relationship.beginConnect("ceremony-token", "https://user:password@hub.example").result,
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
expect(relationship.enrollmentAttempts()).toEqual([]);
|
||||
});
|
||||
|
||||
test("an authenticated external socket can manage the Hub relationship", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
|
||||
const responses = await relationship.manageRelationshipFromExternalSocket();
|
||||
|
||||
expect(responses.map((response) => response.type)).toEqual([
|
||||
"hub.management.daemon.connect.response",
|
||||
"hub.management.daemon.get_status.response",
|
||||
"hub.management.daemon.disconnect.response",
|
||||
]);
|
||||
expect(relationship.enrollmentAttempts()).toHaveLength(1);
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
});
|
||||
|
||||
test("an authenticated browser socket can manage the Hub relationship", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
|
||||
const response = await relationship.connectFromBrowserSocket();
|
||||
|
||||
expect(response.type).toBe("hub.management.daemon.connect.response");
|
||||
expect(relationship.enrollmentAttempts()).toHaveLength(1);
|
||||
expect(relationship.relationshipFile()?.state).toBe("active");
|
||||
});
|
||||
|
||||
test("persists private generated authority before enrollment and active before dialing", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
const privateFileMode = platform() === "win32" ? 0o666 : 0o600;
|
||||
relationship.holdEnrollment();
|
||||
const connecting = relationship.beginConnect("one-time-token");
|
||||
|
||||
const enrollment = await relationship.enrollmentBegins();
|
||||
const pending = relationship.enrollmentInvocation();
|
||||
|
||||
expect(pending).toMatchObject({
|
||||
record: {
|
||||
state: "pending",
|
||||
relationship: {
|
||||
daemonId: enrollment.daemonId,
|
||||
idempotencyKey: enrollment.idempotencyKey,
|
||||
},
|
||||
credential: { secret: expect.any(String) },
|
||||
enrollment: { token: "one-time-token" },
|
||||
identity: { serverId: expect.any(String), daemonPublicKey: expect.any(String) },
|
||||
},
|
||||
});
|
||||
expect(pending.mode).toBe(privateFileMode);
|
||||
const secret = pending.record.credential?.secret;
|
||||
expect(secret).toEqual(expect.any(String));
|
||||
expect(enrollment.credentialVerifier).toBe(
|
||||
createHash("sha256")
|
||||
.update(secret ?? "")
|
||||
.digest("base64url"),
|
||||
);
|
||||
relationship.completeEnrollment();
|
||||
await connecting.result;
|
||||
await relationship.socketDialed();
|
||||
expect(relationship.socketInvocation()).toMatchObject({
|
||||
mode: privateFileMode,
|
||||
record: { state: "active", relationship: { daemonId: enrollment.daemonId } },
|
||||
});
|
||||
});
|
||||
|
||||
test("Hub enrollment cannot widen the daemon's locally granted scopes", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
relationship.returnEnrollmentScopes(["hub.execution.*", "*"]);
|
||||
|
||||
await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
const responses = relationship.sendHubRequestOnLatest({
|
||||
type: "daemon.get_status.request",
|
||||
requestId: "scope-escalation",
|
||||
});
|
||||
|
||||
expect(relationship.relationshipFile()?.relationship.scopes).toEqual(["hub.execution.*"]);
|
||||
expect(responses).toContainEqual({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: "scope-escalation",
|
||||
requestType: "daemon.get_status.request",
|
||||
error: "Session is not authorized for daemon.get_status.request",
|
||||
code: "access_denied",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("a lost enrollment response reuses the exact ceremony", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
relationship.holdEnrollment();
|
||||
const connecting = relationship.beginConnect("same-token");
|
||||
await relationship.enrollmentBegins();
|
||||
relationship.loseEnrollmentResponse();
|
||||
await connecting.result;
|
||||
|
||||
await relationship.retry();
|
||||
const attempts = relationship.enrollmentAttempts();
|
||||
|
||||
expect(attempts).toHaveLength(2);
|
||||
expect(attempts[1]).toEqual(attempts[0]);
|
||||
expect(relationship.enrolledRelationships()).toBe(1);
|
||||
});
|
||||
|
||||
test("a fresh token replaces the token for a pending enrollment ceremony", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
relationship.holdEnrollment();
|
||||
const firstConnect = relationship.beginConnect("expired-token");
|
||||
const firstResult = expect(firstConnect.result).resolves.toMatchObject({
|
||||
state: "reconnecting",
|
||||
});
|
||||
await relationship.enrollmentBegins();
|
||||
relationship.loseEnrollmentResponse();
|
||||
await firstResult;
|
||||
|
||||
relationship.holdEnrollment();
|
||||
const secondConnect = relationship.beginConnect("fresh-token");
|
||||
const secondResult = expect(secondConnect.result).resolves.toMatchObject({
|
||||
state: "connecting",
|
||||
});
|
||||
const retried = await relationship.enrollmentBegins();
|
||||
|
||||
expect(retried.token).toBe("fresh-token");
|
||||
expect(relationship.relationshipFile()?.enrollment?.token).toBe("fresh-token");
|
||||
relationship.completeEnrollment();
|
||||
await secondResult;
|
||||
});
|
||||
|
||||
test("a stale enrollment rejection cannot discard a fresh pending ceremony", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
relationship.holdEnrollment();
|
||||
const expiredConnect = relationship.beginConnect("expired-token");
|
||||
await relationship.enrollmentBegins();
|
||||
|
||||
relationship.holdEnrollment();
|
||||
const freshConnect = relationship.beginConnect("fresh-token");
|
||||
const freshEnrollment = await relationship.enrollmentBegins();
|
||||
relationship.rejectEnrollment(0, 403);
|
||||
await expiredConnect.result;
|
||||
|
||||
expect(relationship.relationshipFile()?.enrollment?.token).toBe("fresh-token");
|
||||
expect(relationship.pendingRelationshipRetries()).toBe(0);
|
||||
|
||||
relationship.completeEnrollment();
|
||||
await freshConnect.result;
|
||||
|
||||
expect(freshEnrollment.token).toBe("fresh-token");
|
||||
expect(relationship.relationshipFile()?.state).toBe("active");
|
||||
expect(relationship.enrollmentAttempts()).toHaveLength(2);
|
||||
expect(relationship.pendingRelationshipRetries()).toBe(0);
|
||||
});
|
||||
|
||||
test("a rejected pending enrollment is discarded without blocking daemon restart", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
relationship.holdEnrollment();
|
||||
const connecting = relationship.beginConnect("expired-token");
|
||||
await relationship.enrollmentBegins();
|
||||
relationship.loseEnrollmentResponse();
|
||||
await connecting.result;
|
||||
relationship.rejectNextEnrollment(401);
|
||||
|
||||
await relationship.restartDaemon();
|
||||
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected" });
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
});
|
||||
|
||||
test("a scheduled enrollment rejection is contained after removing pending authority", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
relationship.holdEnrollment();
|
||||
const connecting = relationship.beginConnect("expired-token");
|
||||
await relationship.enrollmentBegins();
|
||||
relationship.loseEnrollmentResponse();
|
||||
await connecting.result;
|
||||
relationship.rejectNextEnrollment(403);
|
||||
|
||||
const unhandledRejections = await captureUnhandledRejections(() => relationship!.retry());
|
||||
const status = await relationship.status();
|
||||
|
||||
expect(status).toMatchObject({ state: "not_connected" });
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
expect(relationship.pendingRelationshipRetries()).toBe(0);
|
||||
expect(unhandledRejections).toEqual([]);
|
||||
});
|
||||
|
||||
test.each(["{not-json", JSON.stringify({ version: 1, state: "unknown" })])(
|
||||
"an invalid relationship file is quarantined without blocking daemon startup",
|
||||
async (contents) => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.corruptRelationshipFile(contents);
|
||||
|
||||
await relationship.startStoppedDaemon();
|
||||
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected" });
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
["a non-HTTP scheme", "ftp://hub.test"],
|
||||
["embedded credentials", "https://user:password@hub.test"],
|
||||
["a query", "https://hub.test?token=secret"],
|
||||
["a fragment", "https://hub.test#secret"],
|
||||
])("a persisted Hub origin with %s is quarantined before startup", async (_, hubOrigin) => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.corruptRelationshipFile(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
state: "pending",
|
||||
relationship: {
|
||||
daemonId: "daemon-1",
|
||||
idempotencyKey: "ceremony-1",
|
||||
hubOrigin,
|
||||
createdAt: "2026-07-13T00:00:00.000Z",
|
||||
scopes: ["hub.execution.*"],
|
||||
},
|
||||
credential: { secret: "credential" },
|
||||
enrollment: { token: "enrollment-token" },
|
||||
identity: { serverId: "server-1", daemonPublicKey: "public-key" },
|
||||
}),
|
||||
);
|
||||
|
||||
await relationship.startStoppedDaemon();
|
||||
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected" });
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1);
|
||||
expect(relationship.enrollmentAttempts()).toEqual([]);
|
||||
});
|
||||
|
||||
test("a persisted Hub relationship cannot widen its local execution scope", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
const persisted = relationship.relationshipFile();
|
||||
expect(persisted).not.toBeNull();
|
||||
persisted!.relationship.scopes = ["*"];
|
||||
await relationship.corruptRelationshipFile(JSON.stringify(persisted));
|
||||
const socketAttemptsBeforeRestart = relationship.socketAttempts();
|
||||
|
||||
await relationship.startStoppedDaemon();
|
||||
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected" });
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1);
|
||||
expect(relationship.socketAttempts()).toBe(socketAttemptsBeforeRestart);
|
||||
});
|
||||
|
||||
test("a persisted non-WebSocket transport is quarantined before daemon startup", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.corruptRelationshipFile(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
state: "active",
|
||||
relationship: {
|
||||
id: "relationship-1",
|
||||
idempotencyKey: "ceremony-1",
|
||||
hubOrigin: "https://hub.test",
|
||||
createdAt: "2026-07-13T00:00:00.000Z",
|
||||
scopes: ["hub.execution.*"],
|
||||
},
|
||||
credential: { secret: "credential" },
|
||||
transport: { kind: "direct_websocket", webSocketUrl: "ftp://hub.test/daemon" },
|
||||
}),
|
||||
);
|
||||
|
||||
await relationship.startStoppedDaemon();
|
||||
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected" });
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1);
|
||||
expect(relationship.socketAttempts()).toBe(0);
|
||||
});
|
||||
|
||||
test("a persisted WebSocket transport with a fragment is quarantined before startup", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.corruptRelationshipFile(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
state: "active",
|
||||
relationship: {
|
||||
id: "relationship-1",
|
||||
idempotencyKey: "ceremony-1",
|
||||
hubOrigin: "https://hub.test",
|
||||
createdAt: "2026-07-13T00:00:00.000Z",
|
||||
scopes: ["hub.execution.*"],
|
||||
},
|
||||
credential: { secret: "credential" },
|
||||
transport: {
|
||||
kind: "direct_websocket",
|
||||
webSocketUrl: "wss://hub.test/daemon#fragment",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await relationship.startStoppedDaemon();
|
||||
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected" });
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1);
|
||||
expect(relationship.socketAttempts()).toBe(0);
|
||||
});
|
||||
|
||||
test("disconnect revokes an ambiguous pending enrollment before removing local authority", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
relationship.holdEnrollment();
|
||||
const connecting = relationship.beginConnect("one-time-token");
|
||||
const enrollment = await relationship.enrollmentBegins();
|
||||
const credential = relationship.relationshipFile()?.credential?.secret;
|
||||
relationship.loseEnrollmentResponse();
|
||||
await connecting.result;
|
||||
relationship.failRevocations(2);
|
||||
|
||||
const disconnected = await relationship.disconnect();
|
||||
expect(disconnected.state).toBe("disconnecting");
|
||||
expect(relationship.relationshipFile()?.state).toBe("disconnecting");
|
||||
|
||||
await relationship.restartDaemon();
|
||||
await relationship.retry();
|
||||
|
||||
expect(relationship.revocationAttempts()).toBe(3);
|
||||
expect(relationship.latestRevocation()).toEqual(
|
||||
expect.objectContaining({
|
||||
daemonId: enrollment.daemonId,
|
||||
hubOrigin: enrollment.hubOrigin,
|
||||
credential,
|
||||
}),
|
||||
);
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
});
|
||||
|
||||
test("disconnect revokes only after an in-flight enrollment settles", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
relationship.holdEnrollment();
|
||||
const connecting = relationship.beginConnect("one-time-token");
|
||||
const enrollment = await relationship.enrollmentBegins();
|
||||
|
||||
const disconnecting = relationship.beginDisconnect();
|
||||
await relationship.relationshipStateBecomes("disconnecting");
|
||||
expect(relationship.revocationAttempts()).toBe(0);
|
||||
|
||||
relationship.completeEnrollment();
|
||||
await connecting.result;
|
||||
const disconnected = await disconnecting.result;
|
||||
|
||||
expect(disconnected.state).toBe("not_connected");
|
||||
expect(relationship.latestRevocation()?.daemonId).toBe(enrollment.daemonId);
|
||||
expect(relationship.revocationAttempts()).toBe(1);
|
||||
expect(relationship.socketAttempts()).toBe(0);
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
});
|
||||
|
||||
test("daemon restart reconnects the same durable relationship", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
const id = relationship.relationshipFile()?.relationship.daemonId;
|
||||
relationship.connectLatestSocket();
|
||||
await relationship.socketDialed();
|
||||
|
||||
await relationship.restartDaemon();
|
||||
await relationship.socketDialed();
|
||||
|
||||
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(id);
|
||||
expect(relationship.socketAttempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("daemon restart closes an interrupted owned turn without replaying its prompt", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
await relationship.socketDialed();
|
||||
relationship.connectLatestSocket();
|
||||
const daemonId = relationship.relationshipFile()?.relationship.daemonId;
|
||||
const prompt = "sleep 30";
|
||||
relationship.beginOwnedCreate("running-create", "execution-running", {
|
||||
prompt,
|
||||
modeId: "full-access",
|
||||
});
|
||||
const created = await relationship.ownedCreateResult("running-create");
|
||||
const running = await relationship.ownedRunningUpdate(created.payload.agentId!);
|
||||
|
||||
await relationship.restartDaemon();
|
||||
await relationship.socketDialed();
|
||||
relationship.connectLatestSocket();
|
||||
relationship.beginOwnedCreate("running-duplicate", "execution-running", { prompt });
|
||||
const duplicate = await relationship.ownedCreateResult("running-duplicate");
|
||||
expect(duplicate).toMatchObject({
|
||||
payload: {
|
||||
success: true,
|
||||
executionId: "execution-running",
|
||||
agentId: created.payload.agentId,
|
||||
agent: { id: created.payload.agentId, status: "closed" },
|
||||
},
|
||||
});
|
||||
const durableAgentIds = await relationship.durableOwnedAgentIds();
|
||||
|
||||
expect(running).toMatchObject({
|
||||
executionId: "execution-running",
|
||||
agentId: created.payload.agentId,
|
||||
agent: { id: created.payload.agentId, status: "running" },
|
||||
});
|
||||
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId);
|
||||
expect(durableAgentIds).toEqual([created.payload.agentId]);
|
||||
expect(relationship.providerCreations()).toBe(1);
|
||||
expect(relationship.providerResumes()).toBe(0);
|
||||
expect(relationship.providerPromptTexts()).toEqual([prompt]);
|
||||
expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0);
|
||||
});
|
||||
|
||||
test("an owned execution does not persist a daemon-restart prompt intent", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
relationship.beginOwnedCreate("intent-create", "execution-intent", { prompt: "sleep 30" });
|
||||
await relationship.ownedCreateResult("intent-create");
|
||||
|
||||
expect(await relationship.hubExecutionIntentFiles()).toEqual([]);
|
||||
});
|
||||
|
||||
test("an ordinary duplicate create does not replay a completed owned turn", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
const prompt = "respond with exactly: completed once";
|
||||
relationship.beginOwnedCreate("completed-create", "execution-completed", { prompt });
|
||||
const created = await relationship.ownedCreateResult("completed-create");
|
||||
await relationship.ownedTurnCompletion(created.payload.agentId!);
|
||||
|
||||
relationship.beginOwnedCreate("completed-duplicate", "execution-completed", { prompt });
|
||||
const duplicate = await relationship.ownedCreateResult("completed-duplicate");
|
||||
|
||||
expect(duplicate).toMatchObject({
|
||||
type: "hub.execution.agent.create.response",
|
||||
payload: {
|
||||
success: true,
|
||||
executionId: "execution-completed",
|
||||
agentId: created.payload.agentId,
|
||||
agent: { status: "idle" },
|
||||
},
|
||||
});
|
||||
expect(relationship.providerPromptTexts()).toEqual([prompt]);
|
||||
expect(await relationship.durableOwnedAgentIds()).toEqual([created.payload.agentId]);
|
||||
expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(1);
|
||||
});
|
||||
|
||||
test("daemon restart closes a completed owned session without replaying its prompt", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
const prompt = "respond with exactly: already completed";
|
||||
relationship.beginOwnedCreate("idle-create", "execution-idle", { prompt });
|
||||
const created = await relationship.ownedCreateResult("idle-create");
|
||||
await relationship.ownedTurnCompletion(created.payload.agentId!);
|
||||
|
||||
await relationship.restartDaemon();
|
||||
await relationship.socketDialed();
|
||||
relationship.connectLatestSocket();
|
||||
relationship.beginOwnedCreate("idle-retry", "execution-idle", { prompt });
|
||||
const retried = await relationship.ownedCreateResult("idle-retry");
|
||||
|
||||
expect(retried).toMatchObject({
|
||||
payload: {
|
||||
executionId: "execution-idle",
|
||||
agentId: created.payload.agentId,
|
||||
agent: { id: created.payload.agentId, status: "closed" },
|
||||
},
|
||||
});
|
||||
expect(relationship.providerPromptTexts()).toEqual([prompt]);
|
||||
expect(relationship.providerResumes()).toBe(0);
|
||||
expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0);
|
||||
});
|
||||
|
||||
test("daemon restart closes a failed owned session without replaying its prompt", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
const prompt = "emit a turn failure";
|
||||
relationship.beginOwnedCreate("failed-create", "execution-failed", { prompt });
|
||||
const created = await relationship.ownedCreateResult("failed-create");
|
||||
const failed = await relationship.ownedTurnFailure(created.payload.agentId!);
|
||||
|
||||
await relationship.restartDaemon();
|
||||
await relationship.socketDialed();
|
||||
relationship.connectLatestSocket();
|
||||
relationship.beginOwnedCreate("failed-retry", "execution-failed", { prompt });
|
||||
const retried = await relationship.ownedCreateResult("failed-retry");
|
||||
|
||||
expect(failed).toMatchObject({
|
||||
executionId: "execution-failed",
|
||||
agentId: created.payload.agentId,
|
||||
event: { type: "turn_failed" },
|
||||
});
|
||||
expect(retried).toMatchObject({
|
||||
payload: {
|
||||
executionId: "execution-failed",
|
||||
agentId: created.payload.agentId,
|
||||
agent: { id: created.payload.agentId, status: "closed" },
|
||||
},
|
||||
});
|
||||
expect(relationship.providerPromptTexts()).toEqual([prompt]);
|
||||
expect(relationship.providerResumes()).toBe(0);
|
||||
expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0);
|
||||
});
|
||||
|
||||
test("Hub revocation leaves no execution intent artifacts", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
relationship.beginOwnedCreate("revoked-create", "execution-revoked", { prompt: "sleep 30" });
|
||||
await relationship.ownedCreateResult("revoked-create");
|
||||
|
||||
relationship.rejectRelationship(401);
|
||||
|
||||
expect(await relationship.hubExecutionIntentFiles()).toEqual([]);
|
||||
});
|
||||
|
||||
test("stale socket generations cannot replace or unregister the current socket", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.closeSocket(0, 1006);
|
||||
await relationship.retry();
|
||||
relationship.connectSocket(1);
|
||||
|
||||
relationship.connectSocket(0);
|
||||
relationship.closeSocket(0, 1000);
|
||||
const messages = relationship.sendHubRequestOnLatest({
|
||||
type: "daemon.get_status.request",
|
||||
requestId: "still-current",
|
||||
});
|
||||
|
||||
expect(messages).toContainEqual({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: "still-current",
|
||||
requestType: "daemon.get_status.request",
|
||||
error: "Session is not authorized for daemon.get_status.request",
|
||||
code: "access_denied",
|
||||
},
|
||||
});
|
||||
expect(relationship.socketAttempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("replayed create across socket generations shares one pending durable execution", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
relationship.holdAgentCreation();
|
||||
relationship.beginOwnedCreate("first-create");
|
||||
await relationship.agentCreationAttempts(1);
|
||||
|
||||
relationship.closeLatestSocket(1006);
|
||||
await relationship.retry();
|
||||
relationship.connectLatestSocket();
|
||||
relationship.beginOwnedCreate("replayed-create");
|
||||
relationship.finishAgentCreation();
|
||||
|
||||
const replayed = await relationship.ownedCreateResult("replayed-create");
|
||||
const durableAgentIds = await relationship.durableOwnedAgentIds();
|
||||
|
||||
expect(relationship.socketDeliveredResponse(0, "first-create")).toBe(false);
|
||||
expect(replayed).toMatchObject({
|
||||
type: "hub.execution.agent.create.response",
|
||||
payload: {
|
||||
success: true,
|
||||
executionId: "execution-race",
|
||||
agentId: durableAgentIds[0],
|
||||
},
|
||||
});
|
||||
expect(relationship.providerCreations()).toBe(1);
|
||||
expect(durableAgentIds).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("idempotent retry waits for a pending create across socket generations", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
relationship.holdAgentCreation();
|
||||
relationship.beginOwnedCreate("pending-create", "pending-execution");
|
||||
await relationship.agentCreationAttempts(1);
|
||||
|
||||
relationship.closeLatestSocket(1006);
|
||||
await relationship.retry();
|
||||
relationship.connectLatestSocket();
|
||||
relationship.beginOwnedCreate("pending-retry", "pending-execution");
|
||||
relationship.finishAgentCreation();
|
||||
|
||||
await expect(relationship.ownedCreateResult("pending-retry")).resolves.toMatchObject({
|
||||
payload: {
|
||||
executionId: "pending-execution",
|
||||
agentId: expect.any(String),
|
||||
agent: { id: expect.any(String) },
|
||||
},
|
||||
});
|
||||
expect(relationship.providerCreations()).toBe(1);
|
||||
expect(await relationship.durableOwnedAgentIds()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("force disconnect fences a pending create before removing authority", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
relationship.holdAgentCreation();
|
||||
relationship.beginOwnedCreate("cancelled-create", "cancelled-execution", {
|
||||
worktree: { mode: "branch-off", newBranch: "cancelled-hub-create" },
|
||||
});
|
||||
await relationship.agentCreationAttempts(1);
|
||||
|
||||
const disconnecting = relationship.beginDisconnect(true);
|
||||
await relationship.relationshipStateBecomes(null);
|
||||
relationship.finishAgentCreation();
|
||||
await disconnecting.result;
|
||||
|
||||
expect(relationship.activeOwnedAgentIds()).toEqual([]);
|
||||
expect(await relationship.durableOwnedAgentIds()).toEqual([]);
|
||||
expect(await relationship.listedWorktrees()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("re-enrollment gives the same daemon fresh execution authority", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
relationship.beginOwnedCreate("first-create", "first-execution");
|
||||
const first = await relationship.ownedCreateResult("first-create");
|
||||
const daemonId = relationship.relationshipFile()?.relationship.daemonId;
|
||||
|
||||
await relationship.disconnect();
|
||||
await relationship.beginConnect("fresh-token").result;
|
||||
relationship.connectLatestSocket();
|
||||
relationship.beginOwnedCreate("second-create", "second-execution");
|
||||
const second = await relationship.ownedCreateResult("second-create");
|
||||
|
||||
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId);
|
||||
expect(first).toMatchObject({ payload: { success: true, executionId: "first-execution" } });
|
||||
expect(second).toMatchObject({ payload: { success: true, executionId: "second-execution" } });
|
||||
expect(relationship.providerCreations()).toBe(2);
|
||||
});
|
||||
|
||||
test("daemon shutdown fences a pending create before closing owned agents", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
relationship.holdAgentCreation();
|
||||
relationship.beginOwnedCreate("shutdown-create", "execution-shutdown", { prompt: "sleep 30" });
|
||||
await relationship.agentCreationAttempts(1);
|
||||
|
||||
const shutdown = relationship.shutdownDaemon();
|
||||
relationship.finishAgentCreation();
|
||||
await shutdown;
|
||||
|
||||
expect(relationship.socketDeliveredResponse(0, "shutdown-create")).toBe(false);
|
||||
expect(await relationship.durableOwnedAgentIdsOnDisk()).toEqual([]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[4403, "Hub revoked this relationship"],
|
||||
[401, "Hub rejected socket authentication (401)"],
|
||||
[403, "Hub rejected socket authentication (403)"],
|
||||
] as const)("authentication rejection %s revokes permanently", async (code, reason) => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect("private-token").result;
|
||||
const enrollment = relationship.enrollmentAttempts()[0];
|
||||
const secret = relationship.relationshipFile()?.credential?.secret;
|
||||
relationship.rejectRelationship(code);
|
||||
|
||||
await relationship.restartDaemon();
|
||||
const status = await relationship.status();
|
||||
const persisted = relationship.relationshipFile();
|
||||
|
||||
expect(status).toMatchObject({
|
||||
state: "revoked",
|
||||
daemonId: enrollment.daemonId,
|
||||
hub: "https://hub.test",
|
||||
scopes: "hub.execution.*",
|
||||
error: reason,
|
||||
});
|
||||
expect(persisted?.state).toBe("revoked");
|
||||
expect(persisted?.relationship).toMatchObject({
|
||||
daemonId: enrollment.daemonId,
|
||||
hubOrigin: "https://hub.test",
|
||||
scopes: ["hub.execution.*"],
|
||||
});
|
||||
expect(persisted?.reason).toBe(reason);
|
||||
expect(persisted).not.toHaveProperty("credential");
|
||||
expect(persisted).not.toHaveProperty("relationship.idempotencyKey");
|
||||
expect(relationship.socketAttempts()).toBe(1);
|
||||
const loggable = relationship.loggableValues(status);
|
||||
const reconstructed = JSON.stringify({ status, persisted });
|
||||
expect(reconstructed).not.toContain(secret);
|
||||
expect(reconstructed).not.toContain(enrollment.credentialVerifier);
|
||||
expect(reconstructed).not.toContain(enrollment.token);
|
||||
expect(reconstructed).not.toContain(enrollment.idempotencyKey);
|
||||
expect(loggable).not.toContain(secret);
|
||||
expect(loggable).not.toContain(enrollment.credentialVerifier);
|
||||
expect(loggable).not.toContain(enrollment.token);
|
||||
expect(loggable).not.toContain(enrollment.idempotencyKey);
|
||||
});
|
||||
|
||||
test("offline disconnect retries across runtime and restart without opening a socket", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
relationship.failRevocations(3);
|
||||
|
||||
const disconnecting = await relationship.disconnect();
|
||||
await relationship.retry();
|
||||
await relationship.restartDaemon();
|
||||
await relationship.retry();
|
||||
|
||||
expect(disconnecting.state).toBe("disconnecting");
|
||||
expect(relationship.revocationAttempts()).toBe(4);
|
||||
expect(relationship.socketAttempts()).toBe(1);
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
});
|
||||
|
||||
test("successful disconnect clears a transient revocation error", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.failRevocations(1);
|
||||
|
||||
const disconnecting = await relationship.disconnect();
|
||||
await relationship.retry();
|
||||
|
||||
expect(disconnecting).toMatchObject({ state: "disconnecting", error: expect.any(String) });
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected", error: null });
|
||||
});
|
||||
|
||||
test("force disconnect removes local authority and reports the remote warning", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.failRevocations(1);
|
||||
await relationship.disconnect();
|
||||
|
||||
const forced = await relationship.disconnect(true);
|
||||
|
||||
expect(forced.state).toBe("not_connected");
|
||||
expect(forced.warning).toContain("remote revocation");
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
});
|
||||
|
||||
test("disconnect leaves no intent artifacts and shutdown closes the owned agent", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
await relationship.beginConnect().result;
|
||||
relationship.connectLatestSocket();
|
||||
relationship.beginOwnedCreate("orphan-create", "execution-orphan", { prompt: "sleep 30" });
|
||||
const created = await relationship.ownedCreateResult("orphan-create");
|
||||
expect(created).toMatchObject({ payload: { agent: { status: "running" } } });
|
||||
|
||||
await relationship.disconnect(true);
|
||||
expect(await relationship.hubExecutionIntentFiles()).toEqual([]);
|
||||
await relationship.restartDaemon();
|
||||
|
||||
expect(await relationship.storedOwnedStatus(created.payload.agentId!)).toBe("closed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,556 @@
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type pino from "pino";
|
||||
import { z } from "zod";
|
||||
import { ensurePrivateFile, writePrivateFileAtomicSync } from "../private-files.js";
|
||||
import type { WebSocketLike } from "../websocket-server.js";
|
||||
import type { HubExecutionAgents } from "./daemon-executions.js";
|
||||
import type {
|
||||
HubRelationshipRemote,
|
||||
HubSocketConnection,
|
||||
HubSocketEvents,
|
||||
} from "./relationship-remote.js";
|
||||
import { HubEnrollmentRejectedError } from "./relationship-remote.js";
|
||||
import { BoundedExponentialHubRetryPolicy } from "./relationship-retry.js";
|
||||
|
||||
const FILE_NAME = "hub-relationship.json";
|
||||
const HUB_EXECUTION_SCOPE = "hub.execution.*";
|
||||
const SCOPES = [HUB_EXECUTION_SCOPE] as const;
|
||||
const HubOriginSchema = z
|
||||
.string()
|
||||
.url()
|
||||
.superRefine((value, context) => {
|
||||
try {
|
||||
normalizeHubUrl(value);
|
||||
} catch (error) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: error instanceof Error ? error.message : "Invalid Hub URL",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const RelationshipSchema = z.object({
|
||||
daemonId: z.string().min(1),
|
||||
idempotencyKey: z.string().min(1),
|
||||
hubOrigin: HubOriginSchema,
|
||||
createdAt: z.string(),
|
||||
scopes: z.tuple([z.literal(HUB_EXECUTION_SCOPE)]),
|
||||
});
|
||||
const SanitizedRelationshipSchema = RelationshipSchema.omit({ idempotencyKey: true });
|
||||
const CredentialSchema = z.object({ secret: z.string().min(1) });
|
||||
const TransportSchema = z.object({
|
||||
kind: z.literal("direct_websocket"),
|
||||
webSocketUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.refine((value) => ["ws:", "wss:"].includes(new URL(value).protocol))
|
||||
.refine((value) => new URL(value).hash === ""),
|
||||
});
|
||||
const PendingSchema = z.object({
|
||||
version: z.literal(1),
|
||||
state: z.literal("pending"),
|
||||
relationship: RelationshipSchema,
|
||||
credential: CredentialSchema,
|
||||
enrollment: z.object({ token: z.string().min(1) }),
|
||||
identity: z.object({ serverId: z.string().min(1), daemonPublicKey: z.string().min(1) }),
|
||||
});
|
||||
const ActiveSchema = z.object({
|
||||
version: z.literal(1),
|
||||
state: z.literal("active"),
|
||||
relationship: RelationshipSchema,
|
||||
credential: CredentialSchema,
|
||||
transport: TransportSchema,
|
||||
});
|
||||
const DisconnectingSchema = z.object({
|
||||
version: z.literal(1),
|
||||
state: z.literal("disconnecting"),
|
||||
relationship: RelationshipSchema,
|
||||
credential: CredentialSchema,
|
||||
transport: TransportSchema.optional(),
|
||||
});
|
||||
const RevokedSchema = z.object({
|
||||
version: z.literal(1),
|
||||
state: z.literal("revoked"),
|
||||
relationship: SanitizedRelationshipSchema,
|
||||
transport: TransportSchema.optional(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
const RecordSchema = z
|
||||
.discriminatedUnion("state", [PendingSchema, ActiveSchema, DisconnectingSchema, RevokedSchema])
|
||||
.superRefine((record, context) => {
|
||||
if (!("transport" in record) || !record.transport) return;
|
||||
const hub = new URL(record.relationship.hubOrigin);
|
||||
const socket = new URL(record.transport.webSocketUrl);
|
||||
const expectedProtocol = hub.protocol === "https:" ? "wss:" : "ws:";
|
||||
if (socket.protocol === expectedProtocol && socket.host === hub.host) return;
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["transport", "webSocketUrl"],
|
||||
message: "Hub WebSocket URL must match the Hub origin",
|
||||
});
|
||||
});
|
||||
type PendingRecord = z.infer<typeof PendingSchema>;
|
||||
type ActiveRecord = z.infer<typeof ActiveSchema>;
|
||||
type DisconnectingRecord = z.infer<typeof DisconnectingSchema>;
|
||||
type RevokedRecord = z.infer<typeof RevokedSchema>;
|
||||
type HubRelationshipRecord = z.infer<typeof RecordSchema>;
|
||||
|
||||
export type HubConnectionState =
|
||||
| "not_connected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnecting"
|
||||
| "revoked";
|
||||
|
||||
export interface HubRelationshipStatus {
|
||||
state: HubConnectionState;
|
||||
daemonId: string | null;
|
||||
hubOrigin: string | null;
|
||||
scopes: string[];
|
||||
connectedAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface HubRelationshipManagement {
|
||||
connect(input: { hubUrl: string; token: string }): Promise<HubRelationshipStatus>;
|
||||
status(): HubRelationshipStatus;
|
||||
disconnect(input: {
|
||||
force: boolean;
|
||||
}): Promise<{ status: HubRelationshipStatus; warning?: string }>;
|
||||
}
|
||||
|
||||
export interface ScheduledRelationshipTask {
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export interface HubRelationshipClock {
|
||||
now(): Date;
|
||||
schedule(delayMs: number, task: () => void): ScheduledRelationshipTask;
|
||||
}
|
||||
|
||||
export interface HubRelationshipRetryPolicy {
|
||||
delay(attempt: number): number;
|
||||
}
|
||||
|
||||
export interface HubRelationshipControllerOptions {
|
||||
paseoHome: string;
|
||||
serverId: string;
|
||||
daemonPublicKey: string;
|
||||
logger: pino.Logger;
|
||||
remote: HubRelationshipRemote;
|
||||
clock?: HubRelationshipClock;
|
||||
retryPolicy?: HubRelationshipRetryPolicy;
|
||||
createDaemonId?: () => string;
|
||||
attachSocket: (
|
||||
socket: WebSocketLike,
|
||||
options: { daemonId: string; scopes: readonly string[]; agents: HubExecutionAgents },
|
||||
) => Promise<void>;
|
||||
createExecutionAgents: (daemonId: string) => HubExecutionAgents;
|
||||
}
|
||||
|
||||
const systemClock: HubRelationshipClock = {
|
||||
now: () => new Date(),
|
||||
schedule(delayMs, task) {
|
||||
const timer = setTimeout(task, delayMs);
|
||||
timer.unref?.();
|
||||
return { cancel: () => clearTimeout(timer) };
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeHubUrl(value: string): string {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("Hub URL must use HTTP or HTTPS");
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new Error("Hub URL cannot include credentials");
|
||||
}
|
||||
if (url.search || url.hash) {
|
||||
throw new Error("Hub URL cannot include a query or fragment");
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/$/, "");
|
||||
return url.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export class HubRelationshipController implements HubRelationshipManagement {
|
||||
private readonly filePath: string;
|
||||
private readonly clock: HubRelationshipClock;
|
||||
private readonly retryPolicy: HubRelationshipRetryPolicy;
|
||||
private record: HubRelationshipRecord | null;
|
||||
private state: HubConnectionState = "not_connected";
|
||||
private connectedAt: string | null = null;
|
||||
private lastError: string | null = null;
|
||||
private socket: HubSocketConnection | null = null;
|
||||
private retry: ScheduledRelationshipTask | null = null;
|
||||
private generation = 0;
|
||||
private enrollmentGeneration = 0;
|
||||
private retryAttempt = 0;
|
||||
private readonly inFlightEnrollments = new Set<Promise<void>>();
|
||||
private executionAgents: { daemonId: string; value: HubExecutionAgents } | null = null;
|
||||
|
||||
constructor(private readonly options: HubRelationshipControllerOptions) {
|
||||
this.filePath = path.join(options.paseoHome, FILE_NAME);
|
||||
this.clock = options.clock ?? systemClock;
|
||||
this.retryPolicy = options.retryPolicy ?? new BoundedExponentialHubRetryPolicy();
|
||||
this.record = this.load();
|
||||
if (this.record?.state === "revoked") {
|
||||
this.state = "revoked";
|
||||
this.lastError = this.record.reason ?? null;
|
||||
} else if (this.record?.state === "disconnecting") this.state = "disconnecting";
|
||||
else if (this.record) this.state = "connecting";
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.record?.state === "active") this.openSocket(this.record, false);
|
||||
if (this.record?.state === "pending") {
|
||||
const enrollmentGeneration = this.beginEnrollmentAttempt();
|
||||
try {
|
||||
await this.tryEnrollment(this.record, enrollmentGeneration);
|
||||
} catch (error) {
|
||||
if (!(error instanceof HubEnrollmentRejectedError)) throw error;
|
||||
this.options.logger.warn(
|
||||
{ statusCode: error.statusCode },
|
||||
"Discarded rejected pending Hub enrollment during startup",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (this.record?.state === "disconnecting") await this.tryRevocation(this.record);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const pendingExecutionCleanup = this.retireExecutionAgents();
|
||||
this.cancelLifecycle();
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
await pendingExecutionCleanup;
|
||||
}
|
||||
|
||||
status(): HubRelationshipStatus {
|
||||
return {
|
||||
state: this.state,
|
||||
daemonId: this.record?.relationship.daemonId ?? null,
|
||||
hubOrigin: this.record?.relationship.hubOrigin ?? null,
|
||||
scopes: this.record?.relationship.scopes.slice() ?? [],
|
||||
connectedAt: this.connectedAt,
|
||||
lastError: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
async connect(input: { hubUrl: string; token: string }): Promise<HubRelationshipStatus> {
|
||||
if (this.record?.state === "pending") {
|
||||
if (normalizeHubUrl(input.hubUrl) !== this.record.relationship.hubOrigin) {
|
||||
throw new Error("A pending Hub enrollment already exists for a different Hub");
|
||||
}
|
||||
this.record = { ...this.record, enrollment: { token: input.token } };
|
||||
const enrollmentGeneration = this.beginEnrollmentAttempt();
|
||||
this.persist(this.record);
|
||||
this.state = "connecting";
|
||||
this.lastError = null;
|
||||
await this.tryEnrollment(this.record, enrollmentGeneration);
|
||||
return this.status();
|
||||
}
|
||||
if (this.record && this.record.state !== "revoked") {
|
||||
throw new Error("This daemon already has a Hub relationship");
|
||||
}
|
||||
const pending: PendingRecord = {
|
||||
version: 1,
|
||||
state: "pending",
|
||||
relationship: {
|
||||
daemonId: this.options.createDaemonId?.() ?? randomUUID(),
|
||||
idempotencyKey: randomUUID(),
|
||||
hubOrigin: normalizeHubUrl(input.hubUrl),
|
||||
createdAt: this.clock.now().toISOString(),
|
||||
scopes: [...SCOPES],
|
||||
},
|
||||
credential: { secret: randomBytes(32).toString("base64url") },
|
||||
enrollment: { token: input.token },
|
||||
identity: { serverId: this.options.serverId, daemonPublicKey: this.options.daemonPublicKey },
|
||||
};
|
||||
this.persist(pending);
|
||||
this.record = pending;
|
||||
this.state = "connecting";
|
||||
this.lastError = null;
|
||||
await this.tryEnrollment(pending, this.beginEnrollmentAttempt());
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async disconnect(input: {
|
||||
force: boolean;
|
||||
}): Promise<{ status: HubRelationshipStatus; warning?: string }> {
|
||||
const waitForEnrollment = this.record?.state === "pending";
|
||||
const pendingCreateCleanup = this.retireExecutionAgents();
|
||||
this.cancelLifecycle();
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
if (!this.record || this.record.state === "revoked") {
|
||||
this.remove();
|
||||
await pendingCreateCleanup;
|
||||
return { status: this.status() };
|
||||
}
|
||||
if (input.force) {
|
||||
this.remove();
|
||||
await pendingCreateCleanup;
|
||||
return {
|
||||
status: this.status(),
|
||||
warning: "Local Hub credential removed; remote revocation may remain pending.",
|
||||
};
|
||||
}
|
||||
const disconnecting: DisconnectingRecord = {
|
||||
version: 1,
|
||||
state: "disconnecting",
|
||||
relationship: this.record.relationship,
|
||||
credential: this.record.credential,
|
||||
...(this.record.state === "active" ? { transport: this.record.transport } : {}),
|
||||
};
|
||||
this.persist(disconnecting);
|
||||
this.record = disconnecting;
|
||||
this.state = "disconnecting";
|
||||
if (waitForEnrollment) {
|
||||
await Promise.all(this.inFlightEnrollments);
|
||||
}
|
||||
await this.tryRevocation(disconnecting);
|
||||
await pendingCreateCleanup;
|
||||
return { status: this.status() };
|
||||
}
|
||||
|
||||
private async tryEnrollment(pending: PendingRecord, enrollmentGeneration: number): Promise<void> {
|
||||
if (enrollmentGeneration !== this.enrollmentGeneration) return;
|
||||
const verifier = createHash("sha256").update(pending.credential.secret).digest("base64url");
|
||||
const request = this.options.remote.enroll({
|
||||
daemonId: pending.relationship.daemonId,
|
||||
idempotencyKey: pending.relationship.idempotencyKey,
|
||||
hubOrigin: pending.relationship.hubOrigin,
|
||||
token: pending.enrollment.token,
|
||||
serverId: pending.identity.serverId,
|
||||
daemonPublicKey: pending.identity.daemonPublicKey,
|
||||
credentialVerifier: verifier,
|
||||
scopes: pending.relationship.scopes,
|
||||
});
|
||||
const settled = request.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.inFlightEnrollments.add(settled);
|
||||
try {
|
||||
const enrollment = await request;
|
||||
if (enrollmentGeneration !== this.enrollmentGeneration) return;
|
||||
if (
|
||||
enrollment.daemonId !== pending.relationship.daemonId ||
|
||||
!enrollment.scopes.includes(HUB_EXECUTION_SCOPE)
|
||||
) {
|
||||
throw new Error("Hub enrollment response did not match the pending relationship");
|
||||
}
|
||||
const active: ActiveRecord = {
|
||||
version: 1,
|
||||
state: "active",
|
||||
relationship: pending.relationship,
|
||||
credential: pending.credential,
|
||||
transport: { kind: "direct_websocket", webSocketUrl: enrollment.webSocketUrl },
|
||||
};
|
||||
this.persist(active);
|
||||
this.record = active;
|
||||
this.retry = null;
|
||||
this.retryAttempt = 0;
|
||||
this.openSocket(active, false);
|
||||
} catch (error) {
|
||||
if (enrollmentGeneration !== this.enrollmentGeneration) return;
|
||||
if (error instanceof HubEnrollmentRejectedError) {
|
||||
this.remove();
|
||||
throw error;
|
||||
}
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.scheduleEnrollment(pending, enrollmentGeneration);
|
||||
} finally {
|
||||
this.inFlightEnrollments.delete(settled);
|
||||
}
|
||||
}
|
||||
|
||||
private openSocket(record: ActiveRecord, reconnecting: boolean): void {
|
||||
const generation = ++this.generation;
|
||||
this.state = reconnecting ? "reconnecting" : "connecting";
|
||||
const events: HubSocketEvents = {
|
||||
connected: (socket) => this.socketConnected(generation, record, socket),
|
||||
rejected: (statusCode) => this.socketRejected(generation, statusCode),
|
||||
closed: (code) => this.socketClosed(generation, record, code),
|
||||
failed: (error) => this.socketFailed(generation, record, error),
|
||||
};
|
||||
this.socket = this.options.remote.openSocket(
|
||||
{
|
||||
daemonId: record.relationship.daemonId,
|
||||
webSocketUrl: record.transport.webSocketUrl,
|
||||
credential: record.credential.secret,
|
||||
},
|
||||
events,
|
||||
);
|
||||
}
|
||||
|
||||
private socketConnected(generation: number, record: ActiveRecord, socket: WebSocketLike): void {
|
||||
if (generation !== this.generation) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
this.retryAttempt = 0;
|
||||
this.state = "connected";
|
||||
this.connectedAt = this.clock.now().toISOString();
|
||||
this.lastError = null;
|
||||
void this.options.attachSocket(socket, {
|
||||
daemonId: record.relationship.daemonId,
|
||||
scopes: record.relationship.scopes,
|
||||
agents: this.executionAgentsFor(record.relationship.daemonId),
|
||||
});
|
||||
}
|
||||
|
||||
private executionAgentsFor(daemonId: string): HubExecutionAgents {
|
||||
if (this.executionAgents?.daemonId === daemonId) return this.executionAgents.value;
|
||||
const value = this.options.createExecutionAgents(daemonId);
|
||||
this.executionAgents = { daemonId, value };
|
||||
return value;
|
||||
}
|
||||
|
||||
private retireExecutionAgents(): Promise<void> {
|
||||
const executionAgents = this.executionAgents;
|
||||
this.executionAgents = null;
|
||||
return executionAgents?.value.invalidateAuthority() ?? Promise.resolve();
|
||||
}
|
||||
|
||||
private socketRejected(generation: number, statusCode: 401 | 403): void {
|
||||
if (generation !== this.generation) return;
|
||||
this.revoke(`Hub rejected socket authentication (${statusCode})`);
|
||||
}
|
||||
|
||||
private socketClosed(generation: number, record: ActiveRecord, code: number): void {
|
||||
if (generation !== this.generation) return;
|
||||
if (code === 4403) {
|
||||
this.revoke("Hub revoked this relationship");
|
||||
return;
|
||||
}
|
||||
if (this.record?.state === "active") this.scheduleSocket(record);
|
||||
}
|
||||
|
||||
private socketFailed(generation: number, record: ActiveRecord, error: Error): void {
|
||||
if (generation !== this.generation) return;
|
||||
this.lastError = error.message;
|
||||
if (this.record?.state === "active") this.scheduleSocket(record);
|
||||
}
|
||||
|
||||
private scheduleSocket(record: ActiveRecord): void {
|
||||
this.state = "reconnecting";
|
||||
this.schedule(() => this.openSocket(record, true));
|
||||
}
|
||||
|
||||
private scheduleEnrollment(record: PendingRecord, enrollmentGeneration: number): void {
|
||||
if (enrollmentGeneration !== this.enrollmentGeneration) return;
|
||||
this.state = "reconnecting";
|
||||
this.retry?.cancel();
|
||||
const delay = this.retryPolicy.delay(this.retryAttempt++);
|
||||
this.retry = this.clock.schedule(delay, () => {
|
||||
if (enrollmentGeneration !== this.enrollmentGeneration) return;
|
||||
void this.tryEnrollment(record, enrollmentGeneration).catch((error: unknown) => {
|
||||
if (error instanceof HubEnrollmentRejectedError) return;
|
||||
this.options.logger.error({ err: error }, "Scheduled Hub enrollment retry failed");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async tryRevocation(record: DisconnectingRecord): Promise<void> {
|
||||
const generation = this.generation;
|
||||
try {
|
||||
await this.options.remote.revoke({
|
||||
daemonId: record.relationship.daemonId,
|
||||
hubOrigin: record.relationship.hubOrigin,
|
||||
credential: record.credential.secret,
|
||||
});
|
||||
if (generation !== this.generation) return;
|
||||
this.remove();
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return;
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.state = "disconnecting";
|
||||
this.schedule(() => void this.tryRevocation(record));
|
||||
}
|
||||
}
|
||||
|
||||
private schedule(task: () => void): void {
|
||||
this.retry?.cancel();
|
||||
const generation = this.generation;
|
||||
const delay = this.retryPolicy.delay(this.retryAttempt++);
|
||||
this.retry = this.clock.schedule(delay, () => {
|
||||
if (generation === this.generation) task();
|
||||
});
|
||||
}
|
||||
|
||||
private revoke(reason: string): void {
|
||||
void this.retireExecutionAgents();
|
||||
this.cancelLifecycle();
|
||||
if (!this.record) return;
|
||||
const revoked: RevokedRecord = {
|
||||
version: 1,
|
||||
state: "revoked",
|
||||
relationship: {
|
||||
daemonId: this.record.relationship.daemonId,
|
||||
hubOrigin: this.record.relationship.hubOrigin,
|
||||
createdAt: this.record.relationship.createdAt,
|
||||
scopes: this.record.relationship.scopes,
|
||||
},
|
||||
transport: "transport" in this.record ? this.record.transport : undefined,
|
||||
reason,
|
||||
};
|
||||
this.persist(revoked);
|
||||
this.record = revoked;
|
||||
this.state = "revoked";
|
||||
this.lastError = reason;
|
||||
}
|
||||
|
||||
private cancelLifecycle(): void {
|
||||
++this.generation;
|
||||
++this.enrollmentGeneration;
|
||||
this.retry?.cancel();
|
||||
this.retry = null;
|
||||
}
|
||||
|
||||
private beginEnrollmentAttempt(): number {
|
||||
this.retry?.cancel();
|
||||
this.retry = null;
|
||||
this.retryAttempt = 0;
|
||||
return ++this.enrollmentGeneration;
|
||||
}
|
||||
|
||||
private persist(record: HubRelationshipRecord): void {
|
||||
writePrivateFileAtomicSync(this.filePath, `${JSON.stringify(record, null, 2)}\n`);
|
||||
}
|
||||
|
||||
private remove(): void {
|
||||
void this.retireExecutionAgents();
|
||||
this.cancelLifecycle();
|
||||
rmSync(this.filePath, { force: true });
|
||||
this.record = null;
|
||||
this.state = "not_connected";
|
||||
this.connectedAt = null;
|
||||
this.lastError = null;
|
||||
}
|
||||
|
||||
private load(): HubRelationshipRecord | null {
|
||||
if (!existsSync(this.filePath)) return null;
|
||||
let record: HubRelationshipRecord;
|
||||
try {
|
||||
record = RecordSchema.parse(JSON.parse(readFileSync(this.filePath, "utf8")));
|
||||
} catch (error) {
|
||||
const quarantinePath = path.join(
|
||||
path.dirname(this.filePath),
|
||||
`hub-relationship.invalid-${this.clock.now().getTime()}-${randomUUID()}.json`,
|
||||
);
|
||||
renameSync(this.filePath, quarantinePath);
|
||||
ensurePrivateFile(quarantinePath);
|
||||
this.options.logger.error(
|
||||
{ err: error, quarantinePath },
|
||||
"Quarantined invalid Hub relationship authority",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
ensurePrivateFile(this.filePath);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import type { AddressInfo, Socket } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import type { HubExecutionAgents } from "./daemon-executions.js";
|
||||
import {
|
||||
HubRelationshipController,
|
||||
type HubRelationshipClock,
|
||||
type HubRelationshipRetryPolicy,
|
||||
type ScheduledRelationshipTask,
|
||||
} from "./relationship-controller.js";
|
||||
import {
|
||||
DirectHubRelationshipRemote,
|
||||
HubEnrollmentRejectedError,
|
||||
type HubSocketConnection,
|
||||
type HubSocketEvents,
|
||||
} from "./relationship-remote.js";
|
||||
|
||||
const openServers: ReturnType<typeof createServer>[] = [];
|
||||
const openUpgradeHubs: UpgradeRejectingHub[] = [];
|
||||
const openPaseoHomes: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const hub of openUpgradeHubs.splice(0)) hub.destroyConnections();
|
||||
await Promise.all(openServers.splice(0).map((server) => closeServer(server)));
|
||||
await Promise.all(openPaseoHomes.splice(0).map((home) => rm(home, { recursive: true })));
|
||||
});
|
||||
|
||||
test.each([401, 403, 404])(
|
||||
"revocation status %s clears already-invalid local authority",
|
||||
async (status) => {
|
||||
const hubOrigin = await startHubReturning(status);
|
||||
const remote = new DirectHubRelationshipRemote();
|
||||
|
||||
await expect(
|
||||
remote.revoke({ daemonId: "daemon-1", hubOrigin, credential: "invalid" }),
|
||||
).resolves.toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
test("transient revocation failures remain retryable", async () => {
|
||||
const hubOrigin = await startHubReturning(503);
|
||||
const remote = new DirectHubRelationshipRemote();
|
||||
|
||||
await expect(
|
||||
remote.revoke({ daemonId: "daemon-1", hubOrigin, credential: "credential" }),
|
||||
).rejects.toThrow("Hub revocation failed (503)");
|
||||
});
|
||||
|
||||
test.each([408, 429])("transient enrollment status %s remains retryable", async (status) => {
|
||||
const hubOrigin = await startHubReturning(status);
|
||||
const remote = new DirectHubRelationshipRemote();
|
||||
|
||||
const error = await remote
|
||||
.enroll({
|
||||
daemonId: "daemon-1",
|
||||
idempotencyKey: "ceremony-1",
|
||||
hubOrigin,
|
||||
token: "token",
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
scopes: ["hub.execution.*"],
|
||||
})
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).not.toBeInstanceOf(HubEnrollmentRejectedError);
|
||||
expect((error as Error).message).toBe(`Hub enrollment failed (${status})`);
|
||||
});
|
||||
|
||||
test("enrollment rejects a transport URL that cannot open a WebSocket", async () => {
|
||||
const hubOrigin = await startEnrollmentHub("ftp://hub.test/daemon");
|
||||
const remote = new DirectHubRelationshipRemote();
|
||||
|
||||
await expect(
|
||||
remote.enroll({
|
||||
daemonId: "daemon-1",
|
||||
idempotencyKey: "ceremony-1",
|
||||
hubOrigin,
|
||||
token: "token",
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
scopes: ["hub.execution.*"],
|
||||
}),
|
||||
).rejects.toThrow("Hub WebSocket URL must use ws or wss");
|
||||
});
|
||||
|
||||
test("enrollment rejects a WebSocket URL with a fragment", async () => {
|
||||
const hubOrigin = await startEnrollmentHub("ws://hub.test/daemon#fragment");
|
||||
const remote = new DirectHubRelationshipRemote();
|
||||
|
||||
await expect(
|
||||
remote.enroll({
|
||||
daemonId: "daemon-1",
|
||||
idempotencyKey: "ceremony-1",
|
||||
hubOrigin,
|
||||
token: "token",
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
scopes: ["hub.execution.*"],
|
||||
}),
|
||||
).rejects.toThrow("Hub WebSocket URL cannot include a fragment");
|
||||
});
|
||||
|
||||
test("enrollment rejects a WebSocket outside the enrolled Hub authority", async () => {
|
||||
const hubOrigin = await startEnrollmentHub("ws://other-hub.test/daemon");
|
||||
const remote = new DirectHubRelationshipRemote();
|
||||
|
||||
await expect(
|
||||
remote.enroll({
|
||||
daemonId: "daemon-1",
|
||||
idempotencyKey: "ceremony-1",
|
||||
hubOrigin,
|
||||
token: "token",
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
scopes: ["hub.execution.*"],
|
||||
}),
|
||||
).rejects.toThrow("Hub WebSocket URL must match the Hub origin");
|
||||
});
|
||||
|
||||
test.each(["enrollment", "revocation"])("%s HTTP calls are bounded", async (operation) => {
|
||||
const hubOrigin = await startStalledHub();
|
||||
const remote = new DirectHubRelationshipRemote({ requestTimeoutMs: 25 });
|
||||
|
||||
const request =
|
||||
operation === "enrollment"
|
||||
? remote.enroll({
|
||||
daemonId: "daemon-1",
|
||||
idempotencyKey: "ceremony-1",
|
||||
hubOrigin,
|
||||
token: "token",
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
scopes: ["hub.execution.*"],
|
||||
})
|
||||
: remote.revoke({
|
||||
daemonId: "daemon-1",
|
||||
hubOrigin,
|
||||
credential: "credential",
|
||||
});
|
||||
|
||||
await expect(request).rejects.toThrow("Hub request timed out");
|
||||
});
|
||||
|
||||
test.each([401, 403] as const)(
|
||||
"socket authentication status %s is terminal and releases the failed upgrade",
|
||||
async (status) => {
|
||||
const hub = await UpgradeRejectingHub.start([status]);
|
||||
const outcomes = new SocketOutcomes();
|
||||
const remote = new DirectHubRelationshipRemote();
|
||||
|
||||
const socket = remote.openSocket(
|
||||
{
|
||||
daemonId: "daemon-1",
|
||||
webSocketUrl: hub.webSocketUrl,
|
||||
credential: "invalid",
|
||||
},
|
||||
outcomes.events,
|
||||
) as HubSocketConnection & WebSocket;
|
||||
|
||||
expect(await outcomes.next()).toEqual({ type: "rejected", status });
|
||||
await hub.expectAttemptReleased(1);
|
||||
expect(await outcomes.afterCleanup()).toEqual([{ type: "rejected", status }]);
|
||||
expect(socket.readyState).toBe(WebSocket.CLOSED);
|
||||
expect(hub.openConnections()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
test("a transient socket response releases the failed upgrade and reports one connection loss", async () => {
|
||||
const hub = await UpgradeRejectingHub.start([503]);
|
||||
const outcomes = new SocketOutcomes();
|
||||
const remote = new DirectHubRelationshipRemote();
|
||||
|
||||
const socket = remote.openSocket(
|
||||
{
|
||||
daemonId: "daemon-1",
|
||||
webSocketUrl: hub.webSocketUrl,
|
||||
credential: "credential",
|
||||
},
|
||||
outcomes.events,
|
||||
) as HubSocketConnection & WebSocket;
|
||||
|
||||
expect(await outcomes.next()).toEqual({ type: "closed", code: 1006 });
|
||||
await hub.expectAttemptReleased(1);
|
||||
expect(await outcomes.afterCleanup()).toEqual([{ type: "closed", code: 1006 }]);
|
||||
expect(socket.readyState).toBe(WebSocket.CLOSED);
|
||||
expect(hub.openConnections()).toBe(0);
|
||||
});
|
||||
|
||||
test("a stalled socket opening handshake times out and releases the connection", async () => {
|
||||
const hub = await UpgradeRejectingHub.start(["stall"]);
|
||||
const outcomes = new SocketOutcomes();
|
||||
const remote = new DirectHubRelationshipRemote({ requestTimeoutMs: 25 });
|
||||
|
||||
const socket = remote.openSocket(
|
||||
{
|
||||
daemonId: "daemon-1",
|
||||
webSocketUrl: hub.webSocketUrl,
|
||||
credential: "credential",
|
||||
},
|
||||
outcomes.events,
|
||||
) as HubSocketConnection & WebSocket;
|
||||
|
||||
expect(await outcomes.next()).toEqual({
|
||||
type: "failed",
|
||||
message: "Opening handshake has timed out",
|
||||
});
|
||||
expect(await outcomes.afterCleanup()).toEqual([
|
||||
{ type: "failed", message: "Opening handshake has timed out" },
|
||||
]);
|
||||
expect(socket.readyState).toBe(WebSocket.CLOSED);
|
||||
});
|
||||
|
||||
test.each([401, 403] as const)(
|
||||
"controller treats socket authentication status %s as terminal without redialing",
|
||||
async (status) => {
|
||||
const hub = await UpgradeRejectingHub.start([status]);
|
||||
const clock = new ManualRelationshipClock();
|
||||
const controller = await connectController(hub, clock);
|
||||
|
||||
await hub.expectAttemptReleased(1);
|
||||
|
||||
expect(controller.status()).toMatchObject({
|
||||
state: "revoked",
|
||||
lastError: `Hub rejected socket authentication (${status})`,
|
||||
});
|
||||
expect(hub.attemptCount()).toBe(1);
|
||||
expect(clock.pendingTasks()).toBe(0);
|
||||
expect(hub.openConnections()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
test("controller redials after a transient socket response and releases both failed upgrades", async () => {
|
||||
const hub = await UpgradeRejectingHub.start([503, 401]);
|
||||
const clock = new ManualRelationshipClock();
|
||||
const controller = await connectController(hub, clock);
|
||||
|
||||
await hub.expectAttemptReleased(1);
|
||||
expect(controller.status().state).toBe("reconnecting");
|
||||
expect(clock.pendingTasks()).toBe(1);
|
||||
|
||||
clock.runNext();
|
||||
await hub.expectAttemptReleased(2);
|
||||
|
||||
expect(hub.attemptCount()).toBe(2);
|
||||
expect(controller.status().state).toBe("revoked");
|
||||
expect(clock.pendingTasks()).toBe(0);
|
||||
expect(hub.openConnections()).toBe(0);
|
||||
});
|
||||
|
||||
test("controller redials once after a failed upgrade without also handling its close", async () => {
|
||||
const hub = await UpgradeRejectingHub.start([0, 401]);
|
||||
const clock = new ManualRelationshipClock();
|
||||
const controller = await connectController(hub, clock);
|
||||
|
||||
await hub.expectAttemptReleased(1);
|
||||
|
||||
expect(controller.status().state).toBe("reconnecting");
|
||||
expect(clock.scheduledAttempts()).toEqual([0]);
|
||||
expect(clock.pendingTasks()).toBe(1);
|
||||
|
||||
clock.runNext();
|
||||
await hub.expectAttemptReleased(2);
|
||||
|
||||
expect(hub.attemptCount()).toBe(2);
|
||||
expect(controller.status().state).toBe("revoked");
|
||||
expect(clock.pendingTasks()).toBe(0);
|
||||
});
|
||||
|
||||
async function startHubReturning(status: number): Promise<string> {
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(status).end();
|
||||
});
|
||||
openServers.push(server);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address() as AddressInfo;
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
async function startEnrollmentHub(webSocketUrl: string): Promise<string> {
|
||||
const server = createServer(async (request, response) => {
|
||||
let body = "";
|
||||
for await (const chunk of request) body += chunk;
|
||||
const enrollment = JSON.parse(body) as { daemonId: string };
|
||||
response.writeHead(200, { "content-type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
daemonId: enrollment.daemonId,
|
||||
scopes: ["hub.execution.*"],
|
||||
webSocketUrl,
|
||||
}),
|
||||
);
|
||||
});
|
||||
openServers.push(server);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address() as AddressInfo;
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
async function startStalledHub(): Promise<string> {
|
||||
const server = createServer(() => undefined);
|
||||
openServers.push(server);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address() as AddressInfo;
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type SocketOutcome =
|
||||
| { type: "connected" }
|
||||
| { type: "rejected"; status: 401 | 403 }
|
||||
| { type: "closed"; code: number }
|
||||
| { type: "failed"; message: string };
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve(value: T): void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((accept) => {
|
||||
resolve = accept;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
class SocketOutcomes {
|
||||
private readonly outcomes: SocketOutcome[] = [];
|
||||
private nextOutcome = deferred<SocketOutcome>();
|
||||
|
||||
readonly events: HubSocketEvents = {
|
||||
connected: () => this.record({ type: "connected" }),
|
||||
rejected: (status) => this.record({ type: "rejected", status }),
|
||||
closed: (code) => this.record({ type: "closed", code }),
|
||||
failed: (error) => this.record({ type: "failed", message: error.message }),
|
||||
};
|
||||
|
||||
next(): Promise<SocketOutcome> {
|
||||
return withDeadline(this.nextOutcome.promise, "Hub socket produced no outcome");
|
||||
}
|
||||
|
||||
async afterCleanup(): Promise<SocketOutcome[]> {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return this.outcomes.slice();
|
||||
}
|
||||
|
||||
private record(outcome: SocketOutcome): void {
|
||||
this.outcomes.push(outcome);
|
||||
this.nextOutcome.resolve(outcome);
|
||||
this.nextOutcome = deferred<SocketOutcome>();
|
||||
}
|
||||
}
|
||||
|
||||
class UpgradeRejectingHub {
|
||||
private readonly server = createServer(async (request, response) => {
|
||||
let body = "";
|
||||
for await (const chunk of request) body += chunk;
|
||||
const enrollment = JSON.parse(body) as { daemonId: string };
|
||||
response.writeHead(200, { "content-type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
daemonId: enrollment.daemonId,
|
||||
scopes: ["hub.execution.*"],
|
||||
webSocketUrl: this.webSocketUrl,
|
||||
}),
|
||||
);
|
||||
});
|
||||
private readonly sockets = new Set<Socket>();
|
||||
private readonly releasedAttempts = new Map<number, Deferred<void>>();
|
||||
private attemptObserved = deferred<void>();
|
||||
private attempts = 0;
|
||||
|
||||
private constructor(private readonly statuses: Array<number | "stall">) {
|
||||
this.server.on("upgrade", (_request, socket) => {
|
||||
const attempt = ++this.attempts;
|
||||
this.attemptObserved.resolve();
|
||||
this.attemptObserved = deferred<void>();
|
||||
const released = deferred<void>();
|
||||
this.releasedAttempts.set(attempt, released);
|
||||
this.sockets.add(socket);
|
||||
socket.once("close", () => {
|
||||
this.sockets.delete(socket);
|
||||
released.resolve();
|
||||
});
|
||||
const status = this.statuses[attempt - 1] ?? this.statuses.at(-1) ?? 503;
|
||||
if (status === "stall") {
|
||||
socket.resume();
|
||||
return;
|
||||
}
|
||||
if (status === 0) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
socket.end(
|
||||
`HTTP/1.1 ${status} Rejected\r\nContent-Length: 4\r\nConnection: close\r\n\r\ndeny`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static async start(statuses: Array<number | "stall">): Promise<UpgradeRejectingHub> {
|
||||
const hub = new UpgradeRejectingHub(statuses);
|
||||
openUpgradeHubs.push(hub);
|
||||
openServers.push(hub.server);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
hub.server.once("error", reject);
|
||||
hub.server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
return hub;
|
||||
}
|
||||
|
||||
get webSocketUrl(): string {
|
||||
const address = this.server.address() as AddressInfo;
|
||||
return `ws://127.0.0.1:${address.port}/daemon`;
|
||||
}
|
||||
|
||||
get origin(): string {
|
||||
const address = this.server.address() as AddressInfo;
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
attemptCount(): number {
|
||||
return this.attempts;
|
||||
}
|
||||
|
||||
openConnections(): number {
|
||||
return this.sockets.size;
|
||||
}
|
||||
|
||||
destroyConnections(): void {
|
||||
for (const socket of this.sockets) socket.destroy();
|
||||
}
|
||||
|
||||
async expectAttemptReleased(attempt: number): Promise<void> {
|
||||
while (this.attempts < attempt) {
|
||||
await withDeadline(
|
||||
this.attemptObserved.promise,
|
||||
`Hub did not receive socket attempt ${attempt}`,
|
||||
);
|
||||
}
|
||||
const released = this.releasedAttempts.get(attempt);
|
||||
if (!released) throw new Error(`Hub did not receive socket attempt ${attempt}`);
|
||||
await withDeadline(released.promise, `Hub socket attempt ${attempt} remained open`);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
class ManualRelationshipClock implements HubRelationshipClock, HubRelationshipRetryPolicy {
|
||||
private readonly tasks: Array<{ cancelled: boolean; task: () => void }> = [];
|
||||
private readonly attempts: number[] = [];
|
||||
|
||||
now(): Date {
|
||||
return new Date("2026-07-13T00:00:00.000Z");
|
||||
}
|
||||
|
||||
delay(attempt: number): number {
|
||||
this.attempts.push(attempt);
|
||||
return 0;
|
||||
}
|
||||
|
||||
schedule(_delayMs: number, task: () => void): ScheduledRelationshipTask {
|
||||
const scheduled = { cancelled: false, task };
|
||||
this.tasks.push(scheduled);
|
||||
return { cancel: () => (scheduled.cancelled = true) };
|
||||
}
|
||||
|
||||
pendingTasks(): number {
|
||||
return this.tasks.filter((task) => !task.cancelled).length;
|
||||
}
|
||||
|
||||
scheduledAttempts(): number[] {
|
||||
return this.attempts.slice();
|
||||
}
|
||||
|
||||
runNext(): void {
|
||||
const scheduled = this.tasks.shift();
|
||||
if (!scheduled || scheduled.cancelled) throw new Error("No Hub reconnect is scheduled");
|
||||
scheduled.task();
|
||||
}
|
||||
}
|
||||
|
||||
const unusedExecutionAgents: HubExecutionAgents = {
|
||||
create: async () => {
|
||||
throw new Error("Unexpected Hub agent create");
|
||||
},
|
||||
subscribe: () => () => undefined,
|
||||
invalidateAuthority: async () => undefined,
|
||||
};
|
||||
|
||||
async function connectController(
|
||||
hub: UpgradeRejectingHub,
|
||||
clock: ManualRelationshipClock,
|
||||
): Promise<HubRelationshipController> {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-hub-socket-"));
|
||||
openPaseoHomes.push(paseoHome);
|
||||
const controller = new HubRelationshipController({
|
||||
paseoHome,
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "daemon-public-key",
|
||||
logger: pino({ level: "silent" }),
|
||||
remote: new DirectHubRelationshipRemote(),
|
||||
clock,
|
||||
retryPolicy: clock,
|
||||
attachSocket: async () => undefined,
|
||||
createExecutionAgents: () => unusedExecutionAgents,
|
||||
});
|
||||
await controller.connect({ hubUrl: hub.origin, token: "enrollment-token" });
|
||||
return controller;
|
||||
}
|
||||
|
||||
async function withDeadline<T>(promise: Promise<T>, message: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), 2_000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { WebSocket } from "ws";
|
||||
import { z } from "zod";
|
||||
import type { WebSocketLike } from "../websocket-server.js";
|
||||
|
||||
export interface HubEnrollment {
|
||||
daemonId: string;
|
||||
idempotencyKey: string;
|
||||
hubOrigin: string;
|
||||
token: string;
|
||||
serverId: string;
|
||||
daemonPublicKey: string;
|
||||
credentialVerifier: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export interface HubEnrollmentResult {
|
||||
daemonId: string;
|
||||
scopes: string[];
|
||||
webSocketUrl: string;
|
||||
}
|
||||
|
||||
export interface HubRevocation {
|
||||
daemonId: string;
|
||||
hubOrigin: string;
|
||||
credential: string;
|
||||
}
|
||||
|
||||
export interface HubSocketCredentials {
|
||||
daemonId: string;
|
||||
webSocketUrl: string;
|
||||
credential: string;
|
||||
}
|
||||
|
||||
export interface HubSocketEvents {
|
||||
connected(socket: WebSocketLike): void;
|
||||
rejected(statusCode: 401 | 403): void;
|
||||
closed(code: number): void;
|
||||
failed(error: Error): void;
|
||||
}
|
||||
|
||||
export interface HubSocketConnection {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface HubRelationshipRemote {
|
||||
enroll(input: HubEnrollment): Promise<HubEnrollmentResult>;
|
||||
revoke(input: HubRevocation): Promise<void>;
|
||||
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection;
|
||||
}
|
||||
|
||||
export class HubEnrollmentRejectedError extends Error {
|
||||
constructor(readonly statusCode: number) {
|
||||
super(`Hub enrollment failed (${statusCode})`);
|
||||
this.name = "HubEnrollmentRejectedError";
|
||||
}
|
||||
}
|
||||
|
||||
const EnrollmentResultSchema = z.object({
|
||||
daemonId: z.string(),
|
||||
scopes: z.array(z.string()),
|
||||
webSocketUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.refine((value) => ["ws:", "wss:"].includes(new URL(value).protocol), {
|
||||
message: "Hub WebSocket URL must use ws or wss",
|
||||
})
|
||||
.refine((value) => new URL(value).hash === "", {
|
||||
message: "Hub WebSocket URL cannot include a fragment",
|
||||
}),
|
||||
});
|
||||
|
||||
function ensureWebSocketMatchesHubOrigin(hubOrigin: string, webSocketUrl: string): void {
|
||||
const hub = new URL(hubOrigin);
|
||||
const socket = new URL(webSocketUrl);
|
||||
const expectedProtocol = hub.protocol === "https:" ? "wss:" : "ws:";
|
||||
if (socket.protocol !== expectedProtocol || socket.host !== hub.host) {
|
||||
throw new Error("Hub WebSocket URL must match the Hub origin");
|
||||
}
|
||||
}
|
||||
|
||||
export class DirectHubRelationshipRemote implements HubRelationshipRemote {
|
||||
private readonly requestTimeoutMs: number;
|
||||
|
||||
constructor(options: { requestTimeoutMs?: number } = {}) {
|
||||
this.requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
|
||||
}
|
||||
|
||||
async enroll(input: HubEnrollment): Promise<HubEnrollmentResult> {
|
||||
return this.withRequestTimeout(async (signal) => {
|
||||
const response = await fetch(`${input.hubOrigin}/api/daemons/enroll`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${input.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
daemonId: input.daemonId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
serverId: input.serverId,
|
||||
daemonPublicKey: input.daemonPublicKey,
|
||||
credentialVerifier: input.credentialVerifier,
|
||||
scopes: input.scopes,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new HubEnrollmentRejectedError(response.status);
|
||||
}
|
||||
throw new Error(`Hub enrollment failed (${response.status})`);
|
||||
}
|
||||
const enrollment = EnrollmentResultSchema.parse(await response.json());
|
||||
ensureWebSocketMatchesHubOrigin(input.hubOrigin, enrollment.webSocketUrl);
|
||||
return enrollment;
|
||||
});
|
||||
}
|
||||
|
||||
async revoke(input: HubRevocation): Promise<void> {
|
||||
await this.withRequestTimeout(async (signal) => {
|
||||
const response = await fetch(
|
||||
`${input.hubOrigin}/api/daemons/${encodeURIComponent(input.daemonId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { authorization: `Bearer ${input.credential}` },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok && ![401, 403, 404].includes(response.status)) {
|
||||
throw new Error(`Hub revocation failed (${response.status})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection {
|
||||
const socket = new WebSocket(input.webSocketUrl, {
|
||||
handshakeTimeout: this.requestTimeoutMs,
|
||||
headers: {
|
||||
authorization: `Bearer ${input.credential}`,
|
||||
"x-paseo-daemon-id": input.daemonId,
|
||||
},
|
||||
});
|
||||
let settled = false;
|
||||
socket.once("open", () => {
|
||||
if (!settled) events.connected(socket as WebSocketLike);
|
||||
});
|
||||
socket.once("unexpected-response", (_request, response) => {
|
||||
if (settled) {
|
||||
response.destroy();
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
response.destroy();
|
||||
socket.terminate();
|
||||
if (response.statusCode === 401 || response.statusCode === 403) {
|
||||
events.rejected(response.statusCode);
|
||||
return;
|
||||
}
|
||||
events.closed(1006);
|
||||
});
|
||||
socket.once("close", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
events.closed(code);
|
||||
});
|
||||
socket.once("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.terminate();
|
||||
events.failed(error);
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
private async withRequestTimeout<T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
|
||||
timeout.unref?.();
|
||||
try {
|
||||
return await operation(controller.signal);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw new Error("Hub request timed out", { cause: error });
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { BoundedExponentialHubRetryPolicy } from "./relationship-retry.js";
|
||||
|
||||
describe("Hub relationship retry policy", () => {
|
||||
test("keeps jitter within the lower and upper bounds", () => {
|
||||
const lower = new BoundedExponentialHubRetryPolicy(() => 0);
|
||||
const upper = new BoundedExponentialHubRetryPolicy(() => 1);
|
||||
|
||||
expect(lower.delay(0)).toBe(375);
|
||||
expect(upper.delay(0)).toBe(625);
|
||||
});
|
||||
|
||||
test("grows exponentially and caps the base delay", () => {
|
||||
const retry = new BoundedExponentialHubRetryPolicy(() => 0.5);
|
||||
|
||||
expect([0, 1, 2, 3, 4, 5, 6, 20].map((attempt) => retry.delay(attempt))).toEqual([
|
||||
500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000,
|
||||
]);
|
||||
});
|
||||
|
||||
test("never schedules a zero-delay tight loop", () => {
|
||||
const retry = new BoundedExponentialHubRetryPolicy(() => 0);
|
||||
|
||||
expect([0, 1, 2].map((attempt) => retry.delay(attempt))).toEqual([375, 750, 1_500]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { HubRelationshipRetryPolicy } from "./relationship-controller.js";
|
||||
|
||||
const INITIAL_DELAY_MS = 500;
|
||||
const MAX_BASE_DELAY_MS = 30_000;
|
||||
const MIN_JITTER_FACTOR = 0.75;
|
||||
const JITTER_RANGE = 0.5;
|
||||
|
||||
export class BoundedExponentialHubRetryPolicy implements HubRelationshipRetryPolicy {
|
||||
constructor(private readonly random: () => number = Math.random) {}
|
||||
|
||||
delay(attempt: number): number {
|
||||
const base = Math.min(MAX_BASE_DELAY_MS, INITIAL_DELAY_MS * 2 ** attempt);
|
||||
const jitter = MIN_JITTER_FACTOR + this.random() * JITTER_RANGE;
|
||||
return Math.max(1, Math.round(base * jitter));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDaemonVersion } from "./daemon-version.js";
|
||||
import { resolveLogConfig } from "./logger.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import type { PersistedConfig } from "./persisted-config.js";
|
||||
@@ -153,6 +154,24 @@ describe("loadConfig logger config", () => {
|
||||
});
|
||||
|
||||
describe("createRootLogger", () => {
|
||||
it("includes the daemon version in child logger entries", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-version-"));
|
||||
|
||||
const { stdout } = await runLoggerFixture(`
|
||||
const logger = createRootLogger(undefined, { paseoHome: ${JSON.stringify(paseoHome)} });
|
||||
logger.child({ name: "fixture" }).info("versioned child logger");
|
||||
logger.flush();
|
||||
`);
|
||||
|
||||
expect(JSON.parse(stdout)).toMatchObject({
|
||||
pid: expect.any(Number),
|
||||
hostname: expect.any(String),
|
||||
daemonVersion: resolveDaemonVersion(),
|
||||
name: "fixture",
|
||||
msg: "versioned child logger",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes JSON to stdout by default and does not initialize file logging", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-default-"));
|
||||
const missingLogDir = path.join(paseoHome, "logs");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import pretty from "pino-pretty";
|
||||
import { resolveDaemonVersion } from "./daemon-version.js";
|
||||
import type { PersistedConfig } from "./persisted-config.js";
|
||||
import { resolvePaseoHome } from "./paseo-home.js";
|
||||
|
||||
@@ -180,13 +181,15 @@ export function createRootLogger(
|
||||
})
|
||||
: pino.destination({ dest: config.file?.path ?? 1, sync: false });
|
||||
|
||||
return pino(
|
||||
const logger = pino(
|
||||
{
|
||||
level: config.file?.level ?? config.console.level,
|
||||
redact: { paths: REDACT_PATHS, remove: true },
|
||||
},
|
||||
stream,
|
||||
);
|
||||
|
||||
return logger.child({ daemonVersion: resolveDaemonVersion() });
|
||||
}
|
||||
|
||||
export function createChildLogger(parent: pino.Logger, name: string): pino.Logger {
|
||||
|
||||
@@ -32,7 +32,7 @@ import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { createAgentCommand } from "./agent/create-agent/create.js";
|
||||
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import { createLocalCheckoutWorkspace } from "./paseo-worktree-service.js";
|
||||
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
|
||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||
import { LoopService } from "./loop-service.js";
|
||||
@@ -108,12 +108,17 @@ async function createRegistryBackedWorkspaceEnsure(rootDir: string): Promise<{
|
||||
await workspaceRegistry.initialize();
|
||||
await projectRegistry.initialize();
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
});
|
||||
return {
|
||||
workspaceRegistry,
|
||||
ensureWorkspaceForCreate: async (cwd, firstAgentContext) => {
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd, title: firstAgentContext?.prompt ?? null },
|
||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
||||
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||
cwd,
|
||||
firstAgentContext?.prompt ?? null,
|
||||
);
|
||||
return workspace.workspaceId;
|
||||
},
|
||||
|
||||
@@ -241,6 +241,7 @@ type LoopAgentManager = Pick<
|
||||
| "archiveAgent"
|
||||
| "cancelAgentRun"
|
||||
| "closeAgent"
|
||||
| "deleteAgentState"
|
||||
| "getAgent"
|
||||
| "runAgent"
|
||||
| "subscribe"
|
||||
@@ -570,7 +571,7 @@ export class LoopService {
|
||||
await this.options.agentManager.archiveAgent(agentId);
|
||||
return;
|
||||
}
|
||||
await this.options.agentManager.closeAgent(agentId);
|
||||
await this.closeInternalAgent(agentId);
|
||||
} catch (error) {
|
||||
if (!isUnknownLoopAgentError(error, agentId)) {
|
||||
throw error;
|
||||
@@ -578,6 +579,14 @@ export class LoopService {
|
||||
}
|
||||
}
|
||||
|
||||
private async closeInternalAgent(agentId: string): Promise<void> {
|
||||
try {
|
||||
await this.options.agentManager.closeAgent(agentId);
|
||||
} finally {
|
||||
await this.options.agentManager.deleteAgentState(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeLoop(loopId: string, signal: AbortSignal): Promise<void> {
|
||||
const loop = this.requireLoop(loopId);
|
||||
const deadline = loop.maxTimeMs ? Date.now() + loop.maxTimeMs : null;
|
||||
@@ -775,7 +784,7 @@ export class LoopService {
|
||||
if (loop.archive) {
|
||||
await this.options.agentManager.archiveAgent(agent.id);
|
||||
} else {
|
||||
await this.options.agentManager.closeAgent(agent.id);
|
||||
await this.closeInternalAgent(agent.id);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors for internal loop workers.
|
||||
@@ -917,7 +926,7 @@ export class LoopService {
|
||||
if (loop.archive) {
|
||||
await this.options.agentManager.archiveAgent(verifierAgent.id);
|
||||
} else {
|
||||
await this.options.agentManager.closeAgent(verifierAgent.id);
|
||||
await this.closeInternalAgent(verifierAgent.id);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors for internal loop verifiers.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
@@ -10,17 +10,19 @@ import type {
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
import {
|
||||
attemptFirstAgentBranchAutoName,
|
||||
createLocalCheckoutWorkspace,
|
||||
createPaseoWorktree,
|
||||
type CreatePaseoWorktreeDeps,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||
import { createWorktree } from "../utils/worktree.js";
|
||||
import { createWorktree, getPaseoWorktreesRoot } from "../utils/worktree.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
@@ -55,6 +57,7 @@ test("creates a worktree and registers it in the source workspace project withou
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "feature-one",
|
||||
title: "Feature One",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
@@ -68,11 +71,309 @@ test("creates a worktree and registers it in the source workspace project withou
|
||||
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
|
||||
expect(result.workspace.displayName).toBe("feature-one");
|
||||
expect(result.workspace.baseBranch).toBe("main");
|
||||
expect(result.workspace.title).toBe("Feature One");
|
||||
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(events).toEqual([
|
||||
"project:remote:github.com/acme/repo",
|
||||
`workspace:${result.workspace.workspaceId}`,
|
||||
]);
|
||||
expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject);
|
||||
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
|
||||
});
|
||||
|
||||
test("refreshes a source project that became Git while creating a worktree", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps();
|
||||
const sourceProject = {
|
||||
...createPersistedProjectRecordForTest({
|
||||
projectId: "prj_existing-source",
|
||||
rootPath: repoDir,
|
||||
displayName: "Repository",
|
||||
}),
|
||||
kind: "non_git" as const,
|
||||
customName: "My project",
|
||||
};
|
||||
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
|
||||
workspaceId: "ws-main-checkout",
|
||||
projectId: sourceProject.projectId,
|
||||
cwd: repoDir,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
});
|
||||
deps.projects.set(sourceProject.projectId, sourceProject);
|
||||
deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "project-became-git",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result.workspace.projectId).toBe(sourceProject.projectId);
|
||||
expect(deps.projects.get(sourceProject.projectId)).toMatchObject({
|
||||
projectId: sourceProject.projectId,
|
||||
rootPath: repoDir,
|
||||
kind: "git",
|
||||
displayName: "Repository",
|
||||
customName: "My project",
|
||||
archivedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("repairs a legacy source workspace whose project record is missing", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps();
|
||||
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
|
||||
workspaceId: "ws-missing-project",
|
||||
projectId: "project-missing",
|
||||
cwd: repoDir,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
});
|
||||
deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "repaired-source",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result.workspace.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
expect(result.workspace.projectId).not.toBe(sourceWorkspace.projectId);
|
||||
const repairedProject = deps.projects.get(result.workspace.projectId);
|
||||
expect(repairedProject).toMatchObject({
|
||||
projectId: result.workspace.projectId,
|
||||
kind: "git",
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(createRealpathAwarePathMatcher(repoDir)(repairedProject?.rootPath ?? "")).toBe(true);
|
||||
});
|
||||
|
||||
test("uses an equivalent source workspace path when creating a worktree", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const sourceDir = path.join(repoDir, "app");
|
||||
mkdirSync(sourceDir);
|
||||
writeFileSync(path.join(repoDir, "app", ".gitkeep"), "");
|
||||
commitAll(repoDir, "add app");
|
||||
const deps = createDeps();
|
||||
const sourceProject = createPersistedProjectRecordForTest({
|
||||
projectId: "prj_source-folder",
|
||||
rootPath: sourceDir,
|
||||
displayName: "app",
|
||||
});
|
||||
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
|
||||
workspaceId: "ws-source-folder",
|
||||
projectId: sourceProject.projectId,
|
||||
cwd: `${sourceDir}${path.sep}`,
|
||||
kind: "local_checkout",
|
||||
displayName: "app",
|
||||
});
|
||||
deps.projects.set(sourceProject.projectId, sourceProject);
|
||||
deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: sourceDir,
|
||||
worktreeSlug: "equivalent-source",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result.workspace.projectId).toBe(sourceProject.projectId);
|
||||
});
|
||||
|
||||
test("creates a worktree workspace at the selected project subdirectory", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const sourceDir = path.join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
writeFileSync(path.join(sourceDir, "package.json"), "{}\n");
|
||||
commitAll(repoDir, "add subproject");
|
||||
const deps = createDeps();
|
||||
const project = createPersistedProjectRecordForTest({
|
||||
projectId: "prj_selected-subdirectory",
|
||||
rootPath: sourceDir,
|
||||
displayName: "app",
|
||||
});
|
||||
deps.projects.set(project.projectId, project);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: sourceDir,
|
||||
projectId: project.projectId,
|
||||
worktreeSlug: "selected-subdirectory",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result.workspace).toMatchObject({
|
||||
projectId: project.projectId,
|
||||
cwd: path.join(result.worktree.worktreePath, "packages", "app"),
|
||||
worktreeRoot: result.worktree.worktreePath,
|
||||
kind: "worktree",
|
||||
});
|
||||
});
|
||||
|
||||
test("seeds an uncommitted exact-project config into the mapped worktree directory", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const sourceDir = path.join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
writeFileSync(path.join(sourceDir, "package.json"), "{}\n");
|
||||
commitAll(repoDir, "add subproject");
|
||||
const config = JSON.stringify({ worktree: { setup: ["npm install"] } });
|
||||
writeFileSync(path.join(sourceDir, "paseo.json"), config);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: sourceDir,
|
||||
worktreeSlug: "seed-nested-config",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
createDeps(),
|
||||
);
|
||||
|
||||
expect(readFileSync(path.join(result.workspace.cwd, "paseo.json"), "utf8")).toBe(config);
|
||||
expect(existsSync(path.join(result.worktree.worktreePath, "paseo.json"))).toBe(false);
|
||||
});
|
||||
|
||||
test("removes a new worktree when its ref does not contain the selected project directory", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
execFileSync("git", ["branch", "without-subproject"], { cwd: repoDir, stdio: "pipe" });
|
||||
const sourceDir = path.join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
writeFileSync(path.join(sourceDir, "package.json"), "{}\n");
|
||||
commitAll(repoDir, "add subproject");
|
||||
const deps = createDeps();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktreePath = path.join(
|
||||
await getPaseoWorktreesRoot(repoDir, paseoHome),
|
||||
"missing-subproject",
|
||||
);
|
||||
|
||||
await expect(
|
||||
createPaseoWorktree(
|
||||
{
|
||||
cwd: sourceDir,
|
||||
action: "checkout",
|
||||
refName: "without-subproject",
|
||||
worktreeSlug: "missing-subproject",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
).rejects.toThrow("Selected project directory is missing from the worktree");
|
||||
|
||||
expect(deps.workspaces.size).toBe(0);
|
||||
expect(existsSync(worktreePath)).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir, stdio: "pipe" })
|
||||
.toString()
|
||||
.includes("missing-subproject"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("removes a new worktree when workspace persistence fails", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktreePath = path.join(
|
||||
await getPaseoWorktreesRoot(repoDir, paseoHome),
|
||||
"persistence-failure",
|
||||
);
|
||||
|
||||
await expect(
|
||||
createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
projectId: "missing-project",
|
||||
worktreeSlug: "persistence-failure",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
createDeps(),
|
||||
),
|
||||
).rejects.toThrow("Unknown project: missing-project");
|
||||
|
||||
expect(existsSync(worktreePath)).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir, stdio: "pipe" })
|
||||
.toString()
|
||||
.includes("persistence-failure"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("maps a nested cwd from an existing Paseo worktree into the next worktree", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const projectDir = path.join(repoDir, "packages", "app");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(path.join(projectDir, "package.json"), "{}\n");
|
||||
commitAll(repoDir, "add subproject");
|
||||
const deps = createDeps();
|
||||
const source = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "source-worktree",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
const sourceCwd = path.join(source.worktree.worktreePath, "packages", "app");
|
||||
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
cwd: sourceCwd,
|
||||
worktreeSlug: "nested-worktree",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(created.workspace.cwd).toBe(path.join(created.worktree.worktreePath, "packages", "app"));
|
||||
expect(deps.workspaces.get(created.workspace.workspaceId)).toEqual(created.workspace);
|
||||
});
|
||||
|
||||
test("rejects source checkout planning before creating a worktree", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const deps = createDeps();
|
||||
deps.workspaceGitService.getCheckout = async () => {
|
||||
throw new Error("source checkout unavailable");
|
||||
};
|
||||
|
||||
await expect(
|
||||
createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "must-not-create",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
).rejects.toThrow("source checkout unavailable");
|
||||
|
||||
expect(existsSync(path.join(paseoHome, "worktrees"))).toBe(false);
|
||||
expect(Array.from(deps.workspaces.values())).toEqual([]);
|
||||
});
|
||||
|
||||
test("registers a new worktree in the existing root project after the main checkout workspace is removed", async () => {
|
||||
@@ -109,6 +410,35 @@ test("registers a new worktree in the existing root project after the main check
|
||||
expect(Array.from(deps.projects.keys()).sort()).toEqual(["remote:github.com/acme/repo"]);
|
||||
});
|
||||
|
||||
test("an explicit project FK remains unchanged when its worktree comes from another checkout", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps();
|
||||
const project = {
|
||||
...createPersistedProjectRecordForTest({
|
||||
projectId: "prj_explicitproject",
|
||||
rootPath: path.join(tempDir, "unrelated"),
|
||||
displayName: "unrelated",
|
||||
}),
|
||||
kind: "non_git" as const,
|
||||
};
|
||||
deps.projects.set(project.projectId, project);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
projectId: project.projectId,
|
||||
worktreeSlug: "attached-worktree",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result.workspace.projectId).toBe(project.projectId);
|
||||
expect(deps.projects.get(project.projectId)).toEqual(project);
|
||||
});
|
||||
|
||||
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"reuses an existing worktree and still upserts the workspace",
|
||||
@@ -152,19 +482,6 @@ test.skipIf(isPlatform("win32"))(
|
||||
},
|
||||
);
|
||||
|
||||
test("creates a distinct local checkout workspace for the same cwd on every call", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps();
|
||||
|
||||
const first = await createLocalCheckoutWorkspace({ cwd: repoDir }, deps);
|
||||
const second = await createLocalCheckoutWorkspace({ cwd: repoDir }, deps);
|
||||
|
||||
expect(first.cwd).toBe(second.cwd);
|
||||
expect(first.workspaceId).not.toBe(second.workspaceId);
|
||||
expect(deps.workspaces.size).toBe(2);
|
||||
});
|
||||
|
||||
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
@@ -679,7 +996,6 @@ test.skipIf(isPlatform("win32"))(
|
||||
);
|
||||
|
||||
interface TestDeps extends CreatePaseoWorktreeDeps {
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "list" | "upsert">;
|
||||
projects: Map<string, PersistedProjectRecord>;
|
||||
workspaces: Map<string, PersistedWorkspaceRecord>;
|
||||
}
|
||||
@@ -692,28 +1008,73 @@ function createDeps(options?: {
|
||||
const events = options?.events ?? [];
|
||||
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
|
||||
const workspaces = options?.workspaces ?? new Map<string, PersistedWorkspaceRecord>();
|
||||
const projectRegistry: ProjectRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (projectId) => projects.get(projectId) ?? null,
|
||||
getOrCreateActiveByRoot: async (input) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) => !project.archivedAt && areEquivalentPaths(project.rootPath, input.rootPath),
|
||||
);
|
||||
if (existing) return existing;
|
||||
const project = createPersistedProjectRecordForTest({
|
||||
projectId: `prj_${projects.size.toString().padStart(16, "0")}`,
|
||||
rootPath: input.rootPath,
|
||||
displayName: input.displayName,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
},
|
||||
upsert: async (project) => {
|
||||
projects.set(project.projectId, project);
|
||||
},
|
||||
archive: async (projectId, archivedAt) => {
|
||||
const project = projects.get(projectId);
|
||||
if (project) projects.set(projectId, { ...project, archivedAt });
|
||||
},
|
||||
remove: async (projectId) => {
|
||||
projects.delete(projectId);
|
||||
},
|
||||
};
|
||||
const workspaceRegistry: WorkspaceRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
|
||||
update: async (workspaceId, updater) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
if (!workspace) return null;
|
||||
const updated = updater(workspace);
|
||||
workspaces.set(workspaceId, updated);
|
||||
return updated;
|
||||
},
|
||||
upsert: async (record) => {
|
||||
events.push(`workspace:${record.workspaceId}`);
|
||||
workspaces.set(record.workspaceId, record);
|
||||
},
|
||||
archive: async (workspaceId, archivedAt) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
if (workspace) workspaces.set(workspaceId, { ...workspace, archivedAt });
|
||||
},
|
||||
remove: async (workspaceId) => {
|
||||
workspaces.delete(workspaceId);
|
||||
},
|
||||
};
|
||||
const workspaceGitService = createWorkspaceGitServiceStub();
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
return {
|
||||
github: createGitHubServiceStub(),
|
||||
projects,
|
||||
workspaces,
|
||||
projectRegistry: {
|
||||
get: async (projectId) => projects.get(projectId) ?? null,
|
||||
list: async () => Array.from(projects.values()),
|
||||
upsert: async (record) => {
|
||||
events.push(`project:${record.projectId}`);
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
},
|
||||
workspaceRegistry: {
|
||||
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
upsert: async (record) => {
|
||||
events.push(`workspace:${record.workspaceId}`);
|
||||
workspaces.set(record.workspaceId, record);
|
||||
},
|
||||
},
|
||||
workspaceGitService: createWorkspaceGitServiceStub(),
|
||||
workspaceGitService,
|
||||
workspaceProvisioning,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -808,15 +1169,30 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd),
|
||||
getCheckout: async (cwd) => ({
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
getCheckout: async (cwd) => {
|
||||
try {
|
||||
const snapshot = createWorkspaceGitSnapshot(cwd);
|
||||
return {
|
||||
cwd,
|
||||
isGit: snapshot.git.isGit,
|
||||
currentBranch: snapshot.git.currentBranch,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
worktreeRoot: snapshot.git.repoRoot,
|
||||
isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
getSnapshot: async (cwd) => createWorkspaceGitSnapshot(cwd),
|
||||
resolveForge: async () => null,
|
||||
resolveRepoRoot: async (cwd) => {
|
||||
@@ -902,6 +1278,11 @@ function createGitRepo(): { tempDir: string; repoDir: string } {
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
function commitAll(repoDir: string, message: string): void {
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", message], { cwd: repoDir, stdio: "pipe" });
|
||||
}
|
||||
|
||||
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
execFileSync("git", ["checkout", "-b", "pr-123"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
type PersistedWorkspaceRecord,
|
||||
type ProjectRegistry,
|
||||
type WorkspaceRegistry,
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import {
|
||||
classifyDirectoryForProjectMembership,
|
||||
deriveProjectGroupingName,
|
||||
generateWorkspaceId,
|
||||
} from "./workspace-registry-model.js";
|
||||
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { getRealpathAwareRelativePath } from "../utils/path.js";
|
||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import type { WorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import {
|
||||
createWorktreeCore,
|
||||
type CreateWorktreeCoreDeps,
|
||||
type CreateWorktreeCoreInput,
|
||||
} from "./worktree-core.js";
|
||||
import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
|
||||
import {
|
||||
mapWorkspaceRelativeCwdToWorktree,
|
||||
rollbackCreatedPaseoWorktree,
|
||||
seedPaseoConfigFile,
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js";
|
||||
import {
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
|
||||
@@ -32,6 +31,7 @@ import type { FirstAgentContext } from "@getpaseo/protocol/messages";
|
||||
|
||||
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||
projectId?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface CreatePaseoWorktreeResult {
|
||||
@@ -56,36 +56,89 @@ export interface AttemptFirstAgentBranchAutoNameResult {
|
||||
}
|
||||
|
||||
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
workspaceProvisioning: Pick<WorkspaceProvisioningService, "createWorkspaceForWorktree">;
|
||||
}
|
||||
|
||||
export async function createPaseoWorktree(
|
||||
input: CreatePaseoWorktreeInput,
|
||||
deps: CreatePaseoWorktreeDeps,
|
||||
): Promise<CreatePaseoWorktreeResult> {
|
||||
const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService);
|
||||
const createdWorktree = await createWorktreeCore(input, deps);
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
const workspace = await upsertWorkspaceForWorktree({
|
||||
inputCwd: input.cwd,
|
||||
projectId: input.projectId,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
worktree: createdWorktree.worktree,
|
||||
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
|
||||
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
deps,
|
||||
});
|
||||
try {
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({
|
||||
relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd,
|
||||
targetWorktreePath: createdWorktree.worktree.worktreePath,
|
||||
});
|
||||
if (!(await isDirectory(workspaceCwd))) {
|
||||
throw new Error(`Selected project directory is missing from the worktree: ${workspaceCwd}`);
|
||||
}
|
||||
|
||||
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
||||
if (createdWorktree.created) {
|
||||
await seedPaseoConfigFile({
|
||||
sourceCwd: workspaceCwdPlan.inputCwd,
|
||||
targetCwd: workspaceCwd,
|
||||
});
|
||||
}
|
||||
const workspace = await deps.workspaceProvisioning.createWorkspaceForWorktree({
|
||||
sourceCwd: workspaceCwdPlan.inputCwd,
|
||||
projectId: input.projectId,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
cwd: workspaceCwd,
|
||||
worktreeRoot: createdWorktree.worktree.worktreePath,
|
||||
branch: createdWorktree.worktree.branchName || null,
|
||||
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
|
||||
title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
});
|
||||
|
||||
return {
|
||||
worktree: createdWorktree.worktree,
|
||||
intent: createdWorktree.intent,
|
||||
workspace,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
created: createdWorktree.created,
|
||||
};
|
||||
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
||||
|
||||
return {
|
||||
worktree: createdWorktree.worktree,
|
||||
intent: createdWorktree.intent,
|
||||
workspace,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
created: createdWorktree.created,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!createdWorktree.created) {
|
||||
throw error;
|
||||
}
|
||||
return rollbackCreatedPaseoWorktree(
|
||||
{
|
||||
cwd: createdWorktree.repoRoot,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
...(input.runSetup === false ? { teardownCwds: [] } : {}),
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesBaseRoot: input.worktreesRoot,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function isDirectory(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(targetPath)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function planWorkspaceCwdForWorktree(
|
||||
inputCwd: string,
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">,
|
||||
): Promise<{ inputCwd: string; relativeWorkspaceCwd: string }> {
|
||||
const normalizedInputCwd = resolve(inputCwd);
|
||||
const sourceCheckout = await workspaceGitService.getCheckout(normalizedInputCwd);
|
||||
const sourceWorktreePath = sourceCheckout.worktreeRoot ?? normalizedInputCwd;
|
||||
const relativeWorkspaceCwd = getRealpathAwareRelativePath(sourceWorktreePath, normalizedInputCwd);
|
||||
if (relativeWorkspaceCwd === null) {
|
||||
throw new Error(`Workspace cwd is outside its source worktree: ${normalizedInputCwd}`);
|
||||
}
|
||||
return { inputCwd: normalizedInputCwd, relativeWorkspaceCwd };
|
||||
}
|
||||
|
||||
export async function attemptFirstAgentBranchAutoName(options: {
|
||||
@@ -213,257 +266,3 @@ function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertWorkspaceForWorktree(options: {
|
||||
inputCwd: string;
|
||||
projectId?: string;
|
||||
repoRoot: string;
|
||||
worktree: WorktreeConfig;
|
||||
baseBranch?: string | null;
|
||||
title?: string | null;
|
||||
deps: Pick<
|
||||
CreatePaseoWorktreeDeps,
|
||||
"projectRegistry" | "workspaceRegistry" | "workspaceGitService"
|
||||
>;
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedCwd = resolve(options.worktree.worktreePath);
|
||||
const normalizedInputCwd = resolve(options.inputCwd);
|
||||
const normalizedRepoRoot = resolve(options.repoRoot);
|
||||
// Creation never deduplicates by directory: a worktree directory may back
|
||||
// more than one workspace. We still resolve the source project from the
|
||||
// originating checkout, but always mint a fresh workspace record.
|
||||
const sourceProject = await resolveSourceProjectForWorktree({
|
||||
inputCwd: normalizedInputCwd,
|
||||
projectId: options.projectId,
|
||||
repoRoot: normalizedRepoRoot,
|
||||
existingWorkspace: null,
|
||||
deps: options.deps,
|
||||
});
|
||||
const workspaceId = generateWorkspaceId();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await options.deps.projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: sourceProject.projectId,
|
||||
rootPath: sourceProject.rootPath,
|
||||
kind: sourceProject.kind,
|
||||
displayName: sourceProject.displayName,
|
||||
customName: sourceProject.customName,
|
||||
createdAt: sourceProject.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: sourceProject.projectId,
|
||||
cwd: normalizedCwd,
|
||||
kind: "worktree",
|
||||
displayName: options.worktree.branchName || normalizedCwd,
|
||||
branch: options.worktree.branchName || null,
|
||||
baseBranch: options.baseBranch ?? null,
|
||||
title: options.title ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
});
|
||||
|
||||
await options.deps.workspaceRegistry.upsert(workspace);
|
||||
return (await options.deps.workspaceRegistry.get(workspace.workspaceId)) ?? workspace;
|
||||
}
|
||||
|
||||
export interface CreateLocalCheckoutWorkspaceDeps {
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "list" | "upsert">;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "list" | "upsert">;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">;
|
||||
}
|
||||
|
||||
// Always create a NEW workspace record backed by the existing directory `cwd`.
|
||||
// Never reuses a same-cwd record: a directory may back any number of
|
||||
// workspaces. Used by explicit user creation.
|
||||
export async function createLocalCheckoutWorkspace(
|
||||
options: { cwd: string; title?: string | null },
|
||||
deps: CreateLocalCheckoutWorkspaceDeps,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedCwd = resolve(options.cwd);
|
||||
const checkout = await deps.workspaceGitService.getCheckout(normalizedCwd);
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout });
|
||||
const now = new Date().toISOString();
|
||||
const projectRecord = await resolveProjectRecordForMembership({
|
||||
membership,
|
||||
timestamp: now,
|
||||
projectRegistry: deps.projectRegistry,
|
||||
});
|
||||
await deps.projectRegistry.upsert(projectRecord);
|
||||
|
||||
const trimmedTitle = options.title?.trim();
|
||||
// Persist the live git branch into the dedicated `branch` field so
|
||||
// buildWorkspaceCheckout reports the real branch for directory/local_checkout
|
||||
// workspaces too (it reads workspace.branch). Same source deriveWorkspaceDisplayName
|
||||
// reads. HEAD/detached resolves to null — there is no branch to report.
|
||||
const currentBranch = checkout.currentBranch?.trim() ?? null;
|
||||
const branch = currentBranch && currentBranch.toUpperCase() !== "HEAD" ? currentBranch : null;
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: projectRecord.projectId,
|
||||
cwd: normalizedCwd,
|
||||
kind: membership.workspaceKind,
|
||||
displayName: membership.workspaceDisplayName,
|
||||
branch,
|
||||
title: trimmedTitle ? trimmedTitle : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await deps.workspaceRegistry.upsert(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function resolveProjectRecordForMembership(options: {
|
||||
membership: ReturnType<typeof classifyDirectoryForProjectMembership>;
|
||||
timestamp: string;
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "list">;
|
||||
}) {
|
||||
const rootPath = options.membership.projectRootPath;
|
||||
const projects = await options.projectRegistry.list();
|
||||
const existingProject =
|
||||
projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ??
|
||||
projects.find((project) => project.rootPath === rootPath) ??
|
||||
null;
|
||||
|
||||
if (!existingProject) {
|
||||
return createPersistedProjectRecord({
|
||||
projectId: options.membership.projectKey,
|
||||
rootPath,
|
||||
kind: options.membership.projectKind,
|
||||
displayName: options.membership.projectName,
|
||||
createdAt: options.timestamp,
|
||||
updatedAt: options.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...existingProject,
|
||||
rootPath,
|
||||
kind: options.membership.projectKind,
|
||||
archivedAt: null,
|
||||
updatedAt: options.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
interface SourceProjectForWorktree {
|
||||
projectId: string;
|
||||
rootPath: string;
|
||||
kind: "git";
|
||||
displayName: string;
|
||||
customName: string | null;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
function sourceProjectFromRecord(record: {
|
||||
projectId: string;
|
||||
rootPath: string;
|
||||
displayName: string;
|
||||
customName?: string | null;
|
||||
createdAt?: string | null;
|
||||
}): SourceProjectForWorktree {
|
||||
return {
|
||||
projectId: record.projectId,
|
||||
rootPath: record.rootPath,
|
||||
kind: "git",
|
||||
displayName: record.displayName,
|
||||
customName: record.customName ?? null,
|
||||
createdAt: record.createdAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveExplicitProjectForWorktree(options: {
|
||||
projectId: string;
|
||||
projectRegistry: Pick<ProjectRegistry, "get">;
|
||||
}): Promise<SourceProjectForWorktree> {
|
||||
const project = await options.projectRegistry.get(options.projectId);
|
||||
if (!project || project.archivedAt) {
|
||||
throw new Error(`Project not found for worktree: ${options.projectId}`);
|
||||
}
|
||||
return sourceProjectFromRecord(project);
|
||||
}
|
||||
|
||||
async function resolveWorkspaceProjectForWorktree(options: {
|
||||
sourceWorkspace: PersistedWorkspaceRecord;
|
||||
repoRoot: string;
|
||||
projectRegistry: Pick<ProjectRegistry, "get">;
|
||||
}): Promise<SourceProjectForWorktree> {
|
||||
const sourceProject = await options.projectRegistry.get(options.sourceWorkspace.projectId);
|
||||
return sourceProjectFromRecord({
|
||||
projectId: options.sourceWorkspace.projectId,
|
||||
rootPath: sourceProject?.rootPath ?? options.repoRoot,
|
||||
displayName:
|
||||
sourceProject?.displayName ?? deriveProjectGroupingName(options.sourceWorkspace.projectId),
|
||||
customName: sourceProject?.customName ?? null,
|
||||
createdAt: sourceProject?.createdAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveFallbackProjectForWorktree(options: {
|
||||
repoRoot: string;
|
||||
projectRegistry: Pick<ProjectRegistry, "get">;
|
||||
}): Promise<SourceProjectForWorktree> {
|
||||
const existingFallbackProject = await options.projectRegistry.get(options.repoRoot);
|
||||
return sourceProjectFromRecord({
|
||||
projectId: options.repoRoot,
|
||||
rootPath: existingFallbackProject?.rootPath ?? options.repoRoot,
|
||||
displayName:
|
||||
existingFallbackProject?.displayName ?? deriveProjectGroupingName(options.repoRoot),
|
||||
customName: existingFallbackProject?.customName ?? null,
|
||||
createdAt: existingFallbackProject?.createdAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSourceProjectForWorktree(options: {
|
||||
inputCwd: string;
|
||||
projectId?: string;
|
||||
repoRoot: string;
|
||||
existingWorkspace: PersistedWorkspaceRecord | null;
|
||||
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
|
||||
}): Promise<SourceProjectForWorktree> {
|
||||
if (options.projectId) {
|
||||
return resolveExplicitProjectForWorktree({
|
||||
projectId: options.projectId,
|
||||
projectRegistry: options.deps.projectRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
const sourceWorkspace =
|
||||
options.existingWorkspace ??
|
||||
(await findWorkspaceForSource({
|
||||
inputCwd: options.inputCwd,
|
||||
repoRoot: options.repoRoot,
|
||||
workspaceRegistry: options.deps.workspaceRegistry,
|
||||
}));
|
||||
|
||||
if (sourceWorkspace) {
|
||||
return resolveWorkspaceProjectForWorktree({
|
||||
sourceWorkspace,
|
||||
repoRoot: options.repoRoot,
|
||||
projectRegistry: options.deps.projectRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
return resolveFallbackProjectForWorktree({
|
||||
repoRoot: options.repoRoot,
|
||||
projectRegistry: options.deps.projectRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
async function findWorkspaceForSource(options: {
|
||||
inputCwd: string;
|
||||
repoRoot: string;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "list">;
|
||||
}): Promise<PersistedWorkspaceRecord | null> {
|
||||
const workspaces = await options.workspaceRegistry.list();
|
||||
return (
|
||||
workspaces.find((workspace) => workspace.cwd === options.inputCwd && !workspace.archivedAt) ??
|
||||
workspaces.find((workspace) => workspace.cwd === options.repoRoot && !workspace.archivedAt) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
lastUserMessageAt: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: StoredAgentRecord["owner"];
|
||||
} {
|
||||
return {
|
||||
createdAt: new Date(record.createdAt),
|
||||
@@ -117,6 +118,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
|
||||
labels: record.labels,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,8 @@ export function validateScheduleCadence(cadence: ScheduleCadence): void {
|
||||
|
||||
export function computeNextRunAt(cadence: ScheduleCadence, after: Date): Date {
|
||||
if (cadence.type === "every") {
|
||||
// COMPAT(scheduleEveryMs): execute legacy persisted rolling intervals until the
|
||||
// compatibility floor reaches v0.2.0. Added in v0.2.0; remove after 2027-01-17.
|
||||
return new Date(after.getTime() + cadence.everyMs);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js";
|
||||
import { createLocalCheckoutWorkspace } from "../paseo-worktree-service.js";
|
||||
import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { resolveWorkspaceIdForPath } from "../resolve-workspace-id-for-path.js";
|
||||
import { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js";
|
||||
import {
|
||||
@@ -66,15 +66,12 @@ let workspaceArchiveInProgress = false;
|
||||
|
||||
type TestScheduleServiceOptions = Omit<
|
||||
ScheduleServiceOptions,
|
||||
| "createAgent"
|
||||
| "createLocalCheckoutWorkspace"
|
||||
| "createPaseoWorktreeWorkspace"
|
||||
| "archiveWorkspace"
|
||||
"createAgent" | "createDirectoryWorkspace" | "createPaseoWorktreeWorkspace" | "archiveWorkspace"
|
||||
> & {
|
||||
agentManager: AgentManager;
|
||||
providerSnapshotManager: Pick<ProviderSnapshotManager, "resolveCreateConfig">;
|
||||
createAgent?: ScheduleServiceOptions["createAgent"];
|
||||
createLocalCheckoutWorkspace?: ScheduleServiceOptions["createLocalCheckoutWorkspace"];
|
||||
createDirectoryWorkspace?: ScheduleServiceOptions["createDirectoryWorkspace"];
|
||||
createPaseoWorktreeWorkspace?: ScheduleServiceOptions["createPaseoWorktreeWorkspace"];
|
||||
archiveWorkspace?: ScheduleServiceOptions["archiveWorkspace"];
|
||||
};
|
||||
@@ -83,7 +80,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
||||
let workspaceCounter = 0;
|
||||
const workspaces = new Map<string, PersistedWorkspaceRecord>();
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
const createDefaultWorkspace: ScheduleServiceOptions["createLocalCheckoutWorkspace"] = async (
|
||||
const createDefaultWorkspace: ScheduleServiceOptions["createDirectoryWorkspace"] = async (
|
||||
input,
|
||||
) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -141,7 +138,6 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot: null,
|
||||
requestId: "schedule-service-test",
|
||||
},
|
||||
);
|
||||
@@ -163,7 +159,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
||||
},
|
||||
input,
|
||||
)),
|
||||
createLocalCheckoutWorkspace: options.createLocalCheckoutWorkspace ?? createDefaultWorkspace,
|
||||
createDirectoryWorkspace: options.createDirectoryWorkspace ?? createDefaultWorkspace,
|
||||
createPaseoWorktreeWorkspace:
|
||||
options.createPaseoWorktreeWorkspace ??
|
||||
(async (input) => {
|
||||
@@ -182,7 +178,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
|
||||
|
||||
async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promise<{
|
||||
workspaceRegistry: FileBackedWorkspaceRegistry;
|
||||
createLocalCheckoutWorkspace: ScheduleServiceOptions["createLocalCheckoutWorkspace"];
|
||||
createDirectoryWorkspace: ScheduleServiceOptions["createDirectoryWorkspace"];
|
||||
createArchiveWorkspace: (input: {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
@@ -200,12 +196,17 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
|
||||
await workspaceRegistry.initialize();
|
||||
await projectRegistry.initialize();
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
});
|
||||
return {
|
||||
workspaceRegistry,
|
||||
createLocalCheckoutWorkspace: async (input) => {
|
||||
return createLocalCheckoutWorkspace(
|
||||
{ cwd: input.cwd, title: input.firstAgentContext.prompt },
|
||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
||||
createDirectoryWorkspace: async (input) => {
|
||||
return workspaceProvisioning.createWorkspaceForDirectory(
|
||||
input.cwd,
|
||||
input.firstAgentContext.prompt,
|
||||
);
|
||||
},
|
||||
createArchiveWorkspace:
|
||||
@@ -240,7 +241,6 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot: null,
|
||||
requestId: "schedule-service-test",
|
||||
},
|
||||
);
|
||||
@@ -376,6 +376,47 @@ describe("ScheduleService", () => {
|
||||
expect(resumed.nextRunAt).toBe("2026-01-01T00:04:00.000Z");
|
||||
});
|
||||
|
||||
test("lists only active schedules that target existing agents", async () => {
|
||||
const service = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
logger: createTestLogger(),
|
||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
now: () => now,
|
||||
runner: async () => ({ agentId: null, output: "ok" }),
|
||||
});
|
||||
const activeAgentId = "00000000-0000-4000-8000-000000000201";
|
||||
const pausedAgentId = "00000000-0000-4000-8000-000000000202";
|
||||
const completedAgentId = "00000000-0000-4000-8000-000000000203";
|
||||
const cadence = { type: "every" as const, everyMs: 60_000 };
|
||||
|
||||
await service.create({
|
||||
prompt: "Keep active agent resident",
|
||||
cadence,
|
||||
target: { type: "agent", agentId: activeAgentId },
|
||||
});
|
||||
const paused = await service.create({
|
||||
prompt: "Paused heartbeat",
|
||||
cadence,
|
||||
target: { type: "agent", agentId: pausedAgentId },
|
||||
});
|
||||
await service.pause(paused.id);
|
||||
await service.create({
|
||||
prompt: "Completed heartbeat",
|
||||
cadence,
|
||||
target: { type: "agent", agentId: completedAgentId },
|
||||
});
|
||||
await service.completeForAgent(completedAgentId);
|
||||
await service.create({
|
||||
prompt: "Fresh agent each run",
|
||||
cadence,
|
||||
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
|
||||
});
|
||||
|
||||
await expect(service.listActiveAgentTargetIds()).resolves.toEqual(new Set([activeAgentId]));
|
||||
});
|
||||
|
||||
test("completes schedules when max runs is reached", async () => {
|
||||
const service = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
@@ -493,7 +534,7 @@ describe("ScheduleService", () => {
|
||||
});
|
||||
|
||||
test("new-agent schedule records create no workspace until run time", async () => {
|
||||
const { workspaceRegistry, createLocalCheckoutWorkspace: createScheduleLocalWorkspace } =
|
||||
const { workspaceRegistry, createDirectoryWorkspace: createScheduleDirectoryWorkspace } =
|
||||
await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||
const service = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
@@ -501,7 +542,7 @@ describe("ScheduleService", () => {
|
||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
now: () => now,
|
||||
runner: async () => ({ agentId: null, output: "ok" }),
|
||||
});
|
||||
@@ -531,7 +572,7 @@ describe("ScheduleService", () => {
|
||||
test("archiveOnFinish=false local runs create one active workspace per run", async () => {
|
||||
const {
|
||||
workspaceRegistry,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
createArchiveWorkspace,
|
||||
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||
const manager = new AgentManager({
|
||||
@@ -545,7 +586,7 @@ describe("ScheduleService", () => {
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
archiveWorkspace: createArchiveWorkspace({
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
@@ -599,7 +640,7 @@ describe("ScheduleService", () => {
|
||||
test("archiveOnFinish=true archives the run workspace through workspace archive", async () => {
|
||||
const {
|
||||
workspaceRegistry,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
createArchiveWorkspace,
|
||||
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||
const manager = new AgentManager({
|
||||
@@ -620,7 +661,7 @@ describe("ScheduleService", () => {
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
archiveWorkspace: createArchiveWorkspace({
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
@@ -663,7 +704,7 @@ describe("ScheduleService", () => {
|
||||
test("archives the run workspace when scheduled agent creation fails before archive opt-out can preserve an agent", async () => {
|
||||
const {
|
||||
workspaceRegistry,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
createArchiveWorkspace,
|
||||
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
|
||||
const manager = new AgentManager({
|
||||
@@ -678,7 +719,7 @@ describe("ScheduleService", () => {
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
|
||||
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
|
||||
archiveWorkspace: createArchiveWorkspace({
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
@@ -1904,7 +1945,7 @@ describe("ScheduleService", () => {
|
||||
],
|
||||
}));
|
||||
|
||||
const archiveCalls: Array<{ workspaceId: string; repoRoot: string }> = [];
|
||||
const archiveCalls: string[] = [];
|
||||
now = new Date("2026-01-01T00:10:00.000Z");
|
||||
const service2 = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
@@ -1914,13 +1955,13 @@ describe("ScheduleService", () => {
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
now: () => now,
|
||||
runner: async () => ({ agentId: null, output: "ok" }),
|
||||
archiveWorkspace: async (archivedWorkspaceId, repoRoot) => {
|
||||
archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot });
|
||||
archiveWorkspace: async (archivedWorkspaceId) => {
|
||||
archiveCalls.push(archivedWorkspaceId);
|
||||
},
|
||||
});
|
||||
await service2.start();
|
||||
|
||||
expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]);
|
||||
expect(archiveCalls).toEqual([workspaceId]);
|
||||
const inspected = await service2.inspect(created.id);
|
||||
expect(inspected.runs[0]).toMatchObject({
|
||||
status: "failed",
|
||||
@@ -1972,7 +2013,7 @@ describe("ScheduleService", () => {
|
||||
],
|
||||
}));
|
||||
|
||||
const archiveCalls: Array<{ workspaceId: string; repoRoot: string }> = [];
|
||||
const archiveCalls: string[] = [];
|
||||
now = new Date("2026-01-01T00:10:00.000Z");
|
||||
const service2 = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
@@ -1982,13 +2023,13 @@ describe("ScheduleService", () => {
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
now: () => now,
|
||||
runner: async () => ({ agentId: null, output: "ok" }),
|
||||
archiveWorkspace: async (archivedWorkspaceId, repoRoot) => {
|
||||
archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot });
|
||||
archiveWorkspace: async (archivedWorkspaceId) => {
|
||||
archiveCalls.push(archivedWorkspaceId);
|
||||
},
|
||||
});
|
||||
await service2.start();
|
||||
|
||||
expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]);
|
||||
expect(archiveCalls).toEqual([workspaceId]);
|
||||
const inspected = await service2.inspect(created.id);
|
||||
expect(inspected.runs[0]).toMatchObject({
|
||||
status: "failed",
|
||||
|
||||
@@ -204,7 +204,9 @@ type ScheduleAgentManager = Pick<
|
||||
| "hydrateTimelineFromProvider"
|
||||
| "resumeAgentFromPersistence"
|
||||
| "runAgent"
|
||||
| "touchAgentActivity"
|
||||
| "waitForAgentEvent"
|
||||
| "waitForAgentClose"
|
||||
>;
|
||||
|
||||
interface ScheduleWorkspaceCreateInput {
|
||||
@@ -218,13 +220,13 @@ export interface ScheduleServiceOptions {
|
||||
agentManager: ScheduleAgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
createAgent: BoundCreateAgentCommand;
|
||||
createLocalCheckoutWorkspace: (
|
||||
createDirectoryWorkspace: (
|
||||
input: ScheduleWorkspaceCreateInput,
|
||||
) => Promise<PersistedWorkspaceRecord>;
|
||||
createPaseoWorktreeWorkspace: (
|
||||
input: ScheduleWorkspaceCreateInput,
|
||||
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||
archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise<void>;
|
||||
archiveWorkspace: (workspaceId: string) => Promise<void>;
|
||||
now?: () => Date;
|
||||
runner?: (schedule: StoredSchedule, runId: string) => Promise<ScheduleExecutionResult>;
|
||||
}
|
||||
@@ -235,13 +237,13 @@ export class ScheduleService {
|
||||
private readonly agentManager: ScheduleAgentManager;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly createAgent: BoundCreateAgentCommand;
|
||||
private readonly createLocalCheckoutWorkspace: (
|
||||
private readonly createDirectoryWorkspace: (
|
||||
input: ScheduleWorkspaceCreateInput,
|
||||
) => Promise<PersistedWorkspaceRecord>;
|
||||
private readonly createPaseoWorktreeWorkspace: (
|
||||
input: ScheduleWorkspaceCreateInput,
|
||||
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||
private readonly archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise<void>;
|
||||
private readonly archiveWorkspace: (workspaceId: string) => Promise<void>;
|
||||
private readonly now: () => Date;
|
||||
private readonly runner: (
|
||||
schedule: StoredSchedule,
|
||||
@@ -256,7 +258,7 @@ export class ScheduleService {
|
||||
this.agentManager = options.agentManager;
|
||||
this.agentStorage = options.agentStorage;
|
||||
this.createAgent = options.createAgent;
|
||||
this.createLocalCheckoutWorkspace = options.createLocalCheckoutWorkspace;
|
||||
this.createDirectoryWorkspace = options.createDirectoryWorkspace;
|
||||
this.createPaseoWorktreeWorkspace = options.createPaseoWorktreeWorkspace;
|
||||
this.archiveWorkspace = options.archiveWorkspace;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
@@ -368,6 +370,17 @@ export class ScheduleService {
|
||||
return this.store.list();
|
||||
}
|
||||
|
||||
async listActiveAgentTargetIds(): Promise<Set<string>> {
|
||||
const schedules = await this.store.list();
|
||||
const agentIds = new Set<string>();
|
||||
for (const schedule of schedules) {
|
||||
if (schedule.status === "active" && schedule.target.type === "agent") {
|
||||
agentIds.add(schedule.target.agentId);
|
||||
}
|
||||
}
|
||||
return agentIds;
|
||||
}
|
||||
|
||||
async inspect(id: string): Promise<StoredSchedule> {
|
||||
const schedule = await this.store.get(id);
|
||||
if (!schedule) {
|
||||
@@ -579,7 +592,6 @@ export class ScheduleService {
|
||||
private async recoverInterruptedSchedule(scheduleId: string, now: Date): Promise<void> {
|
||||
const interruptedWorkspaces: Array<{
|
||||
workspaceId: string;
|
||||
repoRoot: string;
|
||||
agentId: string | null;
|
||||
runId: string;
|
||||
}> = [];
|
||||
@@ -601,7 +613,6 @@ export class ScheduleService {
|
||||
) {
|
||||
interruptedWorkspaces.push({
|
||||
workspaceId: runningRun.workspaceId,
|
||||
repoRoot: updated.target.config.cwd,
|
||||
agentId: runningRun.agentId,
|
||||
runId: runningRun.id,
|
||||
});
|
||||
@@ -639,7 +650,7 @@ export class ScheduleService {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.archiveWorkspace(interruptedWorkspace.workspaceId, interruptedWorkspace.repoRoot);
|
||||
await this.archiveWorkspace(interruptedWorkspace.workspaceId);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
@@ -930,7 +941,7 @@ export class ScheduleService {
|
||||
shouldArchiveScheduleRunWorkspace({ agentId, archiveOnFinish: config.archiveOnFinish })
|
||||
) {
|
||||
try {
|
||||
await this.archiveWorkspace(workspace.workspaceId, config.cwd);
|
||||
await this.archiveWorkspace(workspace.workspaceId);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
@@ -954,7 +965,7 @@ export class ScheduleService {
|
||||
const firstAgentContext = { prompt };
|
||||
switch (config.isolation ?? "local") {
|
||||
case "local":
|
||||
return this.createLocalCheckoutWorkspace({ cwd: config.cwd, firstAgentContext });
|
||||
return this.createDirectoryWorkspace({ cwd: config.cwd, firstAgentContext });
|
||||
case "worktree":
|
||||
return (await this.createPaseoWorktreeWorkspace({ cwd: config.cwd, firstAgentContext }))
|
||||
.workspace;
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
|
||||
interface MessageWaiter {
|
||||
predicate(message: SessionOutboundMessage): boolean;
|
||||
resolve(message: SessionOutboundMessage): void;
|
||||
reject(error: Error): void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
class ConnectedClient {
|
||||
readonly messages: SessionOutboundMessage[] = [];
|
||||
private readonly waiters: MessageWaiter[] = [];
|
||||
private readonly unsubscribe: () => void;
|
||||
|
||||
constructor(readonly client: DaemonClient) {
|
||||
this.unsubscribe = client.subscribeRawMessages((message) => {
|
||||
this.messages.push(message);
|
||||
for (let waiterIndex = this.waiters.length - 1; waiterIndex >= 0; waiterIndex -= 1) {
|
||||
const waiter = this.waiters[waiterIndex];
|
||||
if (!waiter.predicate(message)) continue;
|
||||
clearTimeout(waiter.timeout);
|
||||
this.waiters.splice(waiterIndex, 1);
|
||||
waiter.resolve(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.messages.length = 0;
|
||||
}
|
||||
|
||||
next(
|
||||
predicate: (message: SessionOutboundMessage) => boolean,
|
||||
description: string,
|
||||
): Promise<SessionOutboundMessage> {
|
||||
const existing = this.messages.find(predicate);
|
||||
if (existing) return Promise.resolve(existing);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
const index = this.waiters.findIndex((waiter) => waiter.resolve === resolve);
|
||||
if (index >= 0) this.waiters.splice(index, 1);
|
||||
reject(new Error(`Timed out waiting for ${description}`));
|
||||
}, 5_000);
|
||||
this.waiters.push({ predicate, resolve, reject, timeout });
|
||||
});
|
||||
}
|
||||
|
||||
hasTimeline(agentId: string): boolean {
|
||||
return this.messages.some(
|
||||
(message) => message.type === "agent_stream" && message.payload.agentId === agentId,
|
||||
);
|
||||
}
|
||||
|
||||
async barrier(label: string): Promise<void> {
|
||||
await this.client.ping({ requestId: `barrier-${label}` });
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.unsubscribe();
|
||||
for (const waiter of this.waiters) {
|
||||
clearTimeout(waiter.timeout);
|
||||
waiter.reject(new Error("Client boundary closed"));
|
||||
}
|
||||
this.waiters.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function isAgentStream(agentId: string) {
|
||||
return (message: SessionOutboundMessage): boolean =>
|
||||
message.type === "agent_stream" && message.payload.agentId === agentId;
|
||||
}
|
||||
|
||||
function isDedicatedAttention(agentId: string) {
|
||||
return (message: SessionOutboundMessage): boolean =>
|
||||
message.type === "agent_attention_required" && message.payload.agentId === agentId;
|
||||
}
|
||||
|
||||
function isLegacyAttention(agentId: string) {
|
||||
return (message: SessionOutboundMessage): boolean =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agentId &&
|
||||
message.payload.event.type === "attention_required";
|
||||
}
|
||||
|
||||
function dedicatedAttentionResult(message: SessionOutboundMessage, timelineLeaked: boolean) {
|
||||
if (message.type !== "agent_attention_required") {
|
||||
throw new Error(`Expected agent_attention_required, received ${message.type}`);
|
||||
}
|
||||
return {
|
||||
type: message.type,
|
||||
shouldNotify: message.payload.shouldNotify,
|
||||
timelineLeaked,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyAttentionResult(message: SessionOutboundMessage) {
|
||||
if (message.type !== "agent_stream" || message.payload.event.type !== "attention_required") {
|
||||
throw new Error(`Expected legacy attention_required agent_stream, received ${message.type}`);
|
||||
}
|
||||
return {
|
||||
type: message.type,
|
||||
eventType: message.payload.event.type,
|
||||
agentId: message.payload.agentId,
|
||||
};
|
||||
}
|
||||
|
||||
let daemon: TestPaseoDaemon;
|
||||
const clients: ConnectedClient[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
daemon = await createTestPaseoDaemon();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const connected of clients) {
|
||||
connected.close();
|
||||
await connected.client.close().catch(() => undefined);
|
||||
}
|
||||
clients.length = 0;
|
||||
await daemon.close();
|
||||
}, 30_000);
|
||||
|
||||
async function connect(input: { clientId: string; selective: boolean }): Promise<ConnectedClient> {
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
clientId: input.clientId,
|
||||
capabilities: { [CLIENT_CAPS.selectiveAgentTimeline]: input.selective },
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
await client.connect();
|
||||
const connected = new ConnectedClient(client);
|
||||
clients.push(connected);
|
||||
return connected;
|
||||
}
|
||||
|
||||
test("subscription acknowledgements stay on the requesting socket of a retained session", async () => {
|
||||
const legacy = await connect({ clientId: "shared-client", selective: false });
|
||||
const capable = await connect({ clientId: "shared-client", selective: true });
|
||||
legacy.clear();
|
||||
capable.clear();
|
||||
|
||||
await capable.client.setAgentTimelineSubscription(["agent-a"]);
|
||||
await capable.barrier("targeted-subscription-ack");
|
||||
|
||||
expect(
|
||||
legacy.messages.some((message) => message.type === "agent.timeline.set_subscription.response"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("real WebSocket sessions enforce selective delivery, retained resets, downgrade, and dedicated attention", async () => {
|
||||
const legacy = await connect({ clientId: "legacy-client", selective: false });
|
||||
let capable = await connect({ clientId: "capable-client", selective: true });
|
||||
const agents = await Promise.all(
|
||||
["A", "B", "C"].map((title) =>
|
||||
legacy.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: "/tmp",
|
||||
title: `Selective ${title}`,
|
||||
modeId: "full-access",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const [agentA, agentB, agentC] = agents;
|
||||
legacy.clear();
|
||||
capable.clear();
|
||||
|
||||
await daemon.daemon.agentManager.emitLiveTimelineItem(agentC.id, {
|
||||
type: "assistant_message",
|
||||
text: "before membership",
|
||||
});
|
||||
await legacy.next(isAgentStream(agentC.id), "legacy global delivery before membership");
|
||||
await capable.barrier("before-membership");
|
||||
expect(capable.hasTimeline(agentC.id)).toBe(false);
|
||||
|
||||
await capable.client.setAgentTimelineSubscription([agentA.id, agentB.id]);
|
||||
legacy.clear();
|
||||
capable.clear();
|
||||
await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, {
|
||||
type: "assistant_message",
|
||||
text: "viewed A",
|
||||
});
|
||||
await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, {
|
||||
type: "assistant_message",
|
||||
text: "viewed B",
|
||||
});
|
||||
await daemon.daemon.agentManager.emitLiveTimelineItem(agentC.id, {
|
||||
type: "assistant_message",
|
||||
text: "unviewed C",
|
||||
});
|
||||
await Promise.all([
|
||||
capable.next(isAgentStream(agentA.id), "capable A delivery"),
|
||||
capable.next(isAgentStream(agentB.id), "capable B delivery"),
|
||||
legacy.next(isAgentStream(agentC.id), "legacy C delivery"),
|
||||
]);
|
||||
await capable.barrier("unviewed-c");
|
||||
expect(capable.hasTimeline(agentC.id)).toBe(false);
|
||||
|
||||
await capable.client.setAgentTimelineSubscription([agentB.id]);
|
||||
legacy.clear();
|
||||
capable.clear();
|
||||
await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, {
|
||||
type: "assistant_message",
|
||||
text: "removed A",
|
||||
});
|
||||
await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, {
|
||||
type: "assistant_message",
|
||||
text: "retained B",
|
||||
});
|
||||
await Promise.all([
|
||||
legacy.next(isAgentStream(agentA.id), "legacy removed A delivery"),
|
||||
capable.next(isAgentStream(agentB.id), "capable retained B delivery"),
|
||||
]);
|
||||
await capable.barrier("removed-a");
|
||||
expect(capable.hasTimeline(agentA.id)).toBe(false);
|
||||
|
||||
capable.close();
|
||||
await capable.client.close();
|
||||
clients.splice(clients.indexOf(capable), 1);
|
||||
capable = await connect({ clientId: "capable-client", selective: true });
|
||||
legacy.clear();
|
||||
capable.clear();
|
||||
await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, {
|
||||
type: "assistant_message",
|
||||
text: "after capable resume",
|
||||
});
|
||||
await legacy.next(isAgentStream(agentB.id), "legacy delivery after capable resume");
|
||||
await capable.barrier("resumed-membership-reset");
|
||||
expect(capable.hasTimeline(agentB.id)).toBe(false);
|
||||
|
||||
capable.client.sendHeartbeat({
|
||||
deviceType: "mobile",
|
||||
focusedAgentId: null,
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
appVisible: true,
|
||||
});
|
||||
legacy.clear();
|
||||
capable.clear();
|
||||
const attention = capable.next(
|
||||
isDedicatedAttention(agentC.id),
|
||||
"capable dedicated attention notification",
|
||||
);
|
||||
const legacyAttention = legacy.next(
|
||||
isLegacyAttention(agentC.id),
|
||||
"legacy attention stream notification",
|
||||
);
|
||||
await legacy.client.sendMessage(agentC.id, "finish attention boundary test");
|
||||
const [attentionMessage, legacyAttentionMessage] = await Promise.all([
|
||||
attention,
|
||||
legacyAttention,
|
||||
]);
|
||||
await capable.barrier("attention-delivery");
|
||||
expect({
|
||||
capable: dedicatedAttentionResult(attentionMessage, capable.hasTimeline(agentC.id)),
|
||||
legacy: legacyAttentionResult(legacyAttentionMessage),
|
||||
}).toEqual({
|
||||
capable: {
|
||||
type: "agent_attention_required",
|
||||
shouldNotify: true,
|
||||
timelineLeaked: false,
|
||||
},
|
||||
legacy: {
|
||||
type: "agent_stream",
|
||||
eventType: "attention_required",
|
||||
agentId: agentC.id,
|
||||
},
|
||||
});
|
||||
|
||||
capable.close();
|
||||
await capable.client.close();
|
||||
clients.splice(clients.indexOf(capable), 1);
|
||||
const downgraded = await connect({ clientId: "capable-client", selective: false });
|
||||
downgraded.clear();
|
||||
await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, {
|
||||
type: "assistant_message",
|
||||
text: "after downgrade",
|
||||
});
|
||||
const downgradedDelivery = await downgraded.next(
|
||||
isAgentStream(agentA.id),
|
||||
"legacy global delivery after capability downgrade",
|
||||
);
|
||||
|
||||
expect(downgradedDelivery.type).toBe("agent_stream");
|
||||
}, 30_000);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
@@ -8,6 +8,7 @@ import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js";
|
||||
import type { CreateAgentOptions } from "./test-utils/index.js";
|
||||
import type { CreateAgentWorktreeTarget } from "./messages.js";
|
||||
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
|
||||
let ctx: DaemonTestContext;
|
||||
const tempRoots: string[] = [];
|
||||
@@ -42,6 +43,18 @@ function createGitRepo(): string {
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
function createGitRepoWithNestedDirectory(): string {
|
||||
const repoDir = createGitRepo();
|
||||
mkdirSync(path.join(repoDir, "packages", "app"), { recursive: true });
|
||||
writeFileSync(path.join(repoDir, "packages", "app", ".gitkeep"), "");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested app"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
async function expectAgentAbsentFromActiveList(agentId: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
@@ -84,8 +97,9 @@ async function expectWorktreeListEmpty(repoDir: string): Promise<void> {
|
||||
async function createAgentInBranchOffWorktree(options?: {
|
||||
autoArchive?: boolean;
|
||||
branchName?: string;
|
||||
repoDir?: string;
|
||||
}): Promise<{ repoDir: string; agentId: string; worktreePath: string }> {
|
||||
const repoDir = createGitRepo();
|
||||
const repoDir = options?.repoDir ?? createGitRepo();
|
||||
const branchName = options?.branchName ?? `agent-lifecycle-${Date.now()}`;
|
||||
const created = await ctx.client.createAgent({
|
||||
config: {
|
||||
@@ -142,6 +156,109 @@ test("create_agent_request creates a worktree and auto-archives both after the f
|
||||
await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
test("create_agent_request auto-archives a nested workspace from an existing Paseo worktree", async () => {
|
||||
const repoDir = createGitRepoWithNestedDirectory();
|
||||
const source = await createAgentInBranchOffWorktree({ branchName: "nested-source", repoDir });
|
||||
await ctx.client.waitForFinish(source.agentId, 10000);
|
||||
const nestedCwd = path.join(source.worktreePath, "packages", "app");
|
||||
|
||||
const created = await ctx.client.createAgent({
|
||||
config: {
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd: nestedCwd,
|
||||
},
|
||||
worktree: {
|
||||
mode: "branch-off",
|
||||
newBranch: "nested-auto-archive",
|
||||
base: "main",
|
||||
},
|
||||
autoArchive: true,
|
||||
initialPrompt: "Say done.",
|
||||
});
|
||||
|
||||
const createdWorktreeRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: created.cwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(
|
||||
createRealpathAwarePathMatcher(path.join(createdWorktreeRoot, "packages", "app"))(created.cwd),
|
||||
).toBe(true);
|
||||
await ctx.client.waitForFinish(created.id, 10000);
|
||||
|
||||
await expectAgentAbsentFromActiveList(created.id);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const workspaces = await ctx.client.fetchWorkspaces();
|
||||
const matchesCreatedWorkspace = createRealpathAwarePathMatcher(created.cwd);
|
||||
return workspaces.entries.some((workspace) =>
|
||||
matchesCreatedWorkspace(workspace.workspaceDirectory),
|
||||
);
|
||||
},
|
||||
{ timeout: 10000, interval: 100 },
|
||||
)
|
||||
.toBe(false);
|
||||
await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false);
|
||||
expect(existsSync(source.worktreePath)).toBe(true);
|
||||
|
||||
await ctx.client.archivePaseoWorktree({ worktreePath: source.worktreePath });
|
||||
}, 30000);
|
||||
|
||||
test("failed nested worktree creation cleans up the created workspace and backing directory", async () => {
|
||||
const repoDir = createGitRepoWithNestedDirectory();
|
||||
const source = await createAgentInBranchOffWorktree({
|
||||
branchName: "nested-failure-source",
|
||||
repoDir,
|
||||
});
|
||||
await ctx.client.waitForFinish(source.agentId, 10000);
|
||||
const nestedCwd = path.join(source.worktreePath, "packages", "app");
|
||||
|
||||
await expect(
|
||||
ctx.client.createAgent({
|
||||
config: { provider: "unknown-provider", cwd: nestedCwd },
|
||||
worktree: {
|
||||
mode: "branch-off",
|
||||
newBranch: "nested-failure-cleanup",
|
||||
base: "main",
|
||||
},
|
||||
initialPrompt: "This agent cannot be created.",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const listed = await ctx.client.getPaseoWorktreeList({ cwd: source.repoDir });
|
||||
return (
|
||||
listed.worktrees.length === 1 &&
|
||||
createRealpathAwarePathMatcher(source.worktreePath)(
|
||||
listed.worktrees[0]?.worktreePath ?? "",
|
||||
)
|
||||
);
|
||||
},
|
||||
{ timeout: 10000, interval: 100 },
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const workspaces = await ctx.client.fetchWorkspaces();
|
||||
return (
|
||||
workspaces.entries.length === 1 &&
|
||||
createRealpathAwarePathMatcher(source.worktreePath)(
|
||||
workspaces.entries[0]?.workspaceDirectory ?? "",
|
||||
)
|
||||
);
|
||||
},
|
||||
{ timeout: 10000, interval: 100 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await ctx.client.archivePaseoWorktree({ worktreePath: source.worktreePath });
|
||||
}, 30000);
|
||||
|
||||
test("create_agent_request with autoArchive archives only the agent when no worktree was created", async () => {
|
||||
const repoDir = createGitRepo();
|
||||
const created = await ctx.client.createAgent({
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
assertPullRequestAutoMergeEnableReady,
|
||||
} from "../services/github-service.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
decodeFileTransferFrame,
|
||||
@@ -17,11 +18,13 @@ import {
|
||||
FileTransferOpcode,
|
||||
type FileTransferFrame,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import { Session } from "./session.js";
|
||||
import { isSessionRpcAllowed, Session } from "./session.js";
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
|
||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { AgentManagerEvent } from "./agent/agent-manager.js";
|
||||
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import { createPersistedProjectRecord } from "./workspace-registry.js";
|
||||
import type { SessionOptions } from "./session.js";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
|
||||
import {
|
||||
@@ -275,6 +278,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
interface SessionForTestOptions {
|
||||
scopes?: readonly string[];
|
||||
agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown };
|
||||
agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown };
|
||||
github?: Partial<ForgeService & GitHubService>;
|
||||
@@ -292,6 +296,7 @@ interface SessionForTestOptions {
|
||||
resolveRepoRoot?: ReturnType<typeof vi.fn>;
|
||||
resolveForge?: ReturnType<typeof vi.fn>;
|
||||
getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>;
|
||||
getProjectSlug?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
|
||||
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
|
||||
@@ -309,6 +314,7 @@ interface SessionForTestOptions {
|
||||
daemonRuntimeConfig?: SessionOptions["daemonRuntimeConfig"];
|
||||
downloadTokenStore?: SessionOptions["downloadTokenStore"];
|
||||
messages?: unknown[];
|
||||
targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>;
|
||||
binaryMessages?: Uint8Array[];
|
||||
}
|
||||
|
||||
@@ -339,13 +345,20 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
// Mirror production: invalidateForge resolves the forge and busts the
|
||||
// adapter's cache. The resolved forge here is github, so delegate to it.
|
||||
invalidateForge: vi.fn((cwd: string) => github.invalidate({ cwd })),
|
||||
getProjectSlug: vi.fn(),
|
||||
...options.workspaceGitService,
|
||||
};
|
||||
const messages = options.messages ?? [];
|
||||
|
||||
return new Session({
|
||||
const sessionOptions: SessionOptions = {
|
||||
clientId: "test-client",
|
||||
onMessage: (message) => messages.push(message),
|
||||
...(options.targetedMessages
|
||||
? {
|
||||
onMessageToSource: (source: object, message: SessionOutboundMessage) =>
|
||||
options.targetedMessages?.push({ source, message }),
|
||||
}
|
||||
: {}),
|
||||
onBinaryMessage: createBinaryMessageHandler(options.binaryMessages),
|
||||
logger,
|
||||
downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(),
|
||||
@@ -357,17 +370,20 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
...options.agentManager,
|
||||
}),
|
||||
agentStorage: asAgentStorage({
|
||||
get: vi.fn().mockResolvedValue(undefined),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
...options.agentStorage,
|
||||
}),
|
||||
projectRegistry: options.projectRegistry ?? {
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn(),
|
||||
getOrCreateActiveByRoot: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
existsOnDisk: vi.fn(),
|
||||
...options.projectRegistry,
|
||||
},
|
||||
workspaceRegistry: options.workspaceRegistry ?? {
|
||||
get: vi.fn(),
|
||||
@@ -399,9 +415,85 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
serverId: options.serverId,
|
||||
daemonVersion: options.daemonVersion,
|
||||
daemonRuntimeConfig: options.daemonRuntimeConfig,
|
||||
});
|
||||
scopes: options.scopes ?? ["*"],
|
||||
};
|
||||
return new Session(sessionOptions);
|
||||
}
|
||||
|
||||
describe("session authorization scopes", () => {
|
||||
test("rejects an RPC outside an exact grant with the generic RPC error", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
scopes: ["hub.execution.agent.create.request"],
|
||||
messages,
|
||||
});
|
||||
|
||||
await session.handleMessage({ type: "ping", requestId: "restricted-ping", clientSentAt: 42 });
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: "restricted-ping",
|
||||
requestType: "ping",
|
||||
error: "Session is not authorized for ping",
|
||||
code: "access_denied",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["*", "ping"],
|
||||
["hub.execution.*", "hub.execution.agent.create.request"],
|
||||
["hub.execution.agent.create.request", "hub.execution.agent.create.request"],
|
||||
])("scope %s authorizes %s", (scope, requestType) => {
|
||||
expect(isSessionRpcAllowed([scope], requestType)).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["hub.execution.*", "hub.management.daemon.get_status.request"],
|
||||
["hub.execution.agent.create.request", "hub.execution.agent.update"],
|
||||
["hub.execution.*", "hub.executions.agent.create.request"],
|
||||
])("scope %s rejects %s", (scope, requestType) => {
|
||||
expect(isSessionRpcAllowed([scope], requestType)).toBe(false);
|
||||
});
|
||||
|
||||
test("replaces a session's scopes without reconstructing the session", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({ scopes: ["hub.execution.*"], messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "ping",
|
||||
requestId: "before-scope-change",
|
||||
clientSentAt: 1,
|
||||
});
|
||||
session.setScopes(["*"]);
|
||||
await session.handleMessage({ type: "ping", requestId: "after-scope-change", clientSentAt: 2 });
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: "before-scope-change",
|
||||
requestType: "ping",
|
||||
error: "Session is not authorized for ping",
|
||||
code: "access_denied",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "pong",
|
||||
payload: {
|
||||
requestId: "after-scope-change",
|
||||
clientSentAt: 2,
|
||||
serverReceivedAt: expect.any(Number),
|
||||
serverSentAt: expect.any(Number),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project command-center RPCs", () => {
|
||||
test("returns normalized repositories from the host GitHub service", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
@@ -516,12 +608,20 @@ describe("project command-center RPCs", () => {
|
||||
const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-")));
|
||||
const directoryPath = join(parentDirectory, "new-project");
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const projectUpsert = vi.fn().mockResolvedValue(undefined);
|
||||
const projectAllocation = vi.fn(async (input) =>
|
||||
createPersistedProjectRecord({
|
||||
projectId: "prj_created_directory",
|
||||
rootPath: input.rootPath,
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
}),
|
||||
);
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
upsert: projectUpsert,
|
||||
getOrCreateActiveByRoot: projectAllocation,
|
||||
},
|
||||
workspaceGitService: {
|
||||
getCheckout: vi.fn(async (cwd: string) => ({
|
||||
@@ -545,7 +645,12 @@ describe("project command-center RPCs", () => {
|
||||
});
|
||||
|
||||
expect(existsSync(directoryPath)).toBe(true);
|
||||
expect(projectUpsert).toHaveBeenCalledOnce();
|
||||
expect(projectAllocation).toHaveBeenCalledWith({
|
||||
rootPath: directoryPath,
|
||||
kind: "non_git",
|
||||
displayName: "new-project",
|
||||
timestamp: expect.any(String),
|
||||
});
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "project.create_directory.response",
|
||||
@@ -553,7 +658,7 @@ describe("project command-center RPCs", () => {
|
||||
requestId: "req-create-directory",
|
||||
directoryPath,
|
||||
project: {
|
||||
projectId: directoryPath,
|
||||
projectId: "prj_created_directory",
|
||||
projectDisplayName: "new-project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: directoryPath,
|
||||
@@ -576,8 +681,7 @@ describe("project command-center RPCs", () => {
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
upsert: vi.fn().mockRejectedValue(new Error("registry unavailable")),
|
||||
getOrCreateActiveByRoot: vi.fn().mockRejectedValue(new Error("registry unavailable")),
|
||||
},
|
||||
workspaceGitService: {
|
||||
getCheckout: vi.fn(async (cwd: string) => ({
|
||||
@@ -4032,17 +4136,17 @@ describe("session paseo worktree creation handling", () => {
|
||||
});
|
||||
|
||||
describe("session workspace script handling", () => {
|
||||
test("passes service-owned git metadata into workspace script spawning", async () => {
|
||||
test("passes the project slug and cached branch into workspace script spawning", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspaceGitService = {
|
||||
peekSnapshot: vi.fn(() => null),
|
||||
getWorkspaceGitMetadata: vi.fn().mockResolvedValue({
|
||||
projectKind: "git",
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
workspaceDisplayName: "feature/service-scripts",
|
||||
projectSlug: "paseo",
|
||||
const snapshot = createWorkspaceGitSnapshot("/tmp/repo", {
|
||||
git: {
|
||||
currentBranch: "feature/service-scripts",
|
||||
}),
|
||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||
},
|
||||
});
|
||||
const workspaceGitService = {
|
||||
peekSnapshot: vi.fn(() => snapshot),
|
||||
getProjectSlug: vi.fn().mockResolvedValue("paseo"),
|
||||
};
|
||||
const workspaceRegistry = {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
@@ -4075,8 +4179,6 @@ describe("session workspace script handling", () => {
|
||||
requestId: "request-script",
|
||||
});
|
||||
|
||||
expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledTimes(1);
|
||||
expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(spawnMocks.spawnWorkspaceScript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repoRoot: "/tmp/repo",
|
||||
@@ -4554,13 +4656,266 @@ describe("chat/schedule/loop dispatch routing (behavior preservation)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("replaces a capable session's complete viewed timeline set", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({ messages });
|
||||
session.updateClientCapabilities({ selective_agent_timeline: true });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "agent.timeline.set_subscription.request",
|
||||
agentIds: ["agent-b", "agent-a", "agent-a"],
|
||||
requestId: "timeline-subscription-1",
|
||||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "agent.timeline.set_subscription.response",
|
||||
payload: {
|
||||
agentIds: ["agent-a", "agent-b"],
|
||||
requestId: "timeline-subscription-1",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("acknowledges a timeline subscription only to its socket source", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
|
||||
const session = createSessionForTest({ messages, targetedMessages });
|
||||
const capableSocket = {};
|
||||
session.updateClientCapabilities({ selective_agent_timeline: true }, capableSocket);
|
||||
|
||||
await session.handleMessage(
|
||||
{
|
||||
type: "agent.timeline.set_subscription.request",
|
||||
agentIds: ["agent-a"],
|
||||
requestId: "timeline-subscription-targeted",
|
||||
},
|
||||
capableSocket,
|
||||
);
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
expect(targetedMessages).toEqual([
|
||||
{
|
||||
source: capableSocket,
|
||||
message: {
|
||||
type: "agent.timeline.set_subscription.response",
|
||||
payload: {
|
||||
agentIds: ["agent-a"],
|
||||
requestId: "timeline-subscription-targeted",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("unions viewed timelines across socket sources and removes detached sources", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const agentEventListeners: Array<(event: AgentManagerEvent) => void> = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
agentManager: {
|
||||
subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => {
|
||||
agentEventListeners.push(listener);
|
||||
return () => {};
|
||||
}),
|
||||
},
|
||||
});
|
||||
session.updateClientCapabilities({ selective_agent_timeline: true });
|
||||
const firstSocket = {};
|
||||
const secondSocket = {};
|
||||
session.updateClientCapabilities({ selective_agent_timeline: true }, firstSocket);
|
||||
session.updateClientCapabilities({ selective_agent_timeline: true }, secondSocket);
|
||||
|
||||
await session.handleMessage(
|
||||
{
|
||||
type: "agent.timeline.set_subscription.request",
|
||||
agentIds: ["agent-a"],
|
||||
requestId: "timeline-subscription-a",
|
||||
},
|
||||
firstSocket,
|
||||
);
|
||||
await session.handleMessage(
|
||||
{
|
||||
type: "agent.timeline.set_subscription.request",
|
||||
agentIds: ["agent-b"],
|
||||
requestId: "timeline-subscription-b",
|
||||
},
|
||||
secondSocket,
|
||||
);
|
||||
messages.length = 0;
|
||||
|
||||
if (agentEventListeners.length === 0) throw new Error("Agent event listener was not installed");
|
||||
const forward = (event: AgentManagerEvent) => {
|
||||
for (const listener of agentEventListeners) listener(event);
|
||||
};
|
||||
forward({
|
||||
type: "agent_stream",
|
||||
agentId: "agent-a",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "mock",
|
||||
item: { type: "assistant_message", messageId: "message-a", text: "A" },
|
||||
},
|
||||
});
|
||||
forward({
|
||||
type: "agent_stream",
|
||||
agentId: "agent-b",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "mock",
|
||||
item: { type: "assistant_message", messageId: "message-b", text: "B" },
|
||||
},
|
||||
});
|
||||
expect(messages.filter((message) => message.type === "agent_stream")).toHaveLength(2);
|
||||
|
||||
const legacySocket = {};
|
||||
session.updateClientCapabilities(null, legacySocket);
|
||||
expect(session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, legacySocket)).toBe(false);
|
||||
expect(session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, firstSocket)).toBe(true);
|
||||
messages.length = 0;
|
||||
forward({
|
||||
type: "agent_stream",
|
||||
agentId: "agent-not-viewed",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "mock",
|
||||
item: { type: "assistant_message", messageId: "message-legacy", text: "legacy" },
|
||||
},
|
||||
});
|
||||
expect(messages.some((message) => message.type === "agent_stream")).toBe(true);
|
||||
|
||||
session.clearAgentTimelineSubscription(legacySocket);
|
||||
|
||||
session.clearAgentTimelineSubscription(firstSocket);
|
||||
messages.length = 0;
|
||||
forward({
|
||||
type: "agent_stream",
|
||||
agentId: "agent-a",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "mock",
|
||||
item: { type: "assistant_message", messageId: "message-a-2", text: "detached A" },
|
||||
},
|
||||
});
|
||||
forward({
|
||||
type: "agent_stream",
|
||||
agentId: "agent-b",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "mock",
|
||||
item: { type: "assistant_message", messageId: "message-b-2", text: "retained B" },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
messages.flatMap((message) =>
|
||||
message.type === "agent_stream" ? [message.payload.agentId] : [],
|
||||
),
|
||||
).toEqual(["agent-b"]);
|
||||
});
|
||||
|
||||
test("keeps selective delivery scoped per socket when a retained session also has a legacy socket", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
|
||||
const agentEventListeners: Array<(event: AgentManagerEvent) => void> = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
targetedMessages,
|
||||
agentManager: {
|
||||
subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => {
|
||||
agentEventListeners.push(listener);
|
||||
return () => {};
|
||||
}),
|
||||
},
|
||||
});
|
||||
const legacySocket = {};
|
||||
const selectiveSocket = {};
|
||||
session.updateClientCapabilities(null, legacySocket);
|
||||
session.updateClientCapabilities({ selective_agent_timeline: true }, selectiveSocket);
|
||||
await session.handleMessage(
|
||||
{
|
||||
type: "agent.timeline.set_subscription.request",
|
||||
agentIds: ["viewed-agent"],
|
||||
requestId: "timeline-subscription-selective",
|
||||
},
|
||||
selectiveSocket,
|
||||
);
|
||||
targetedMessages.length = 0;
|
||||
|
||||
const listener = agentEventListeners[0];
|
||||
if (!listener) throw new Error("Agent event listener was not installed");
|
||||
listener({
|
||||
type: "agent_stream",
|
||||
agentId: "not-viewed-agent",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "mock",
|
||||
item: { type: "assistant_message", messageId: "message-global", text: "global" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
expect(targetedMessages).toEqual([
|
||||
{
|
||||
source: legacySocket,
|
||||
message: expect.objectContaining({
|
||||
type: "agent_stream",
|
||||
payload: expect.objectContaining({ agentId: "not-viewed-agent" }),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("sends project updates only to capable sockets in a retained session", () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
|
||||
const session = createSessionForTest({ messages, targetedMessages });
|
||||
const legacySocket = {};
|
||||
const capableSocket = {};
|
||||
session.updateClientCapabilities(null, legacySocket);
|
||||
session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true }, capableSocket);
|
||||
|
||||
session.emitProjectUpdate({
|
||||
kind: "upsert",
|
||||
project: createPersistedProjectRecord({
|
||||
projectId: "project-capable-socket",
|
||||
rootPath: "/tmp/project-capable-socket",
|
||||
kind: "git",
|
||||
displayName: "project-capable-socket",
|
||||
createdAt: "2026-07-17T00:00:00.000Z",
|
||||
updatedAt: "2026-07-17T00:00:00.000Z",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
expect(targetedMessages).toEqual([
|
||||
{
|
||||
source: capableSocket,
|
||||
message: expect.objectContaining({
|
||||
type: "project.update",
|
||||
payload: expect.objectContaining({ kind: "upsert" }),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("agent config setters", () => {
|
||||
function liveAgentManager(overrides: { [K in keyof SessionOptions["agentManager"]]?: unknown }): {
|
||||
[K in keyof SessionOptions["agentManager"]]?: unknown;
|
||||
} {
|
||||
return {
|
||||
waitForAgentClose: vi.fn().mockResolvedValue(undefined),
|
||||
touchAgentActivity: vi.fn(() => ({ id: "agent-1" })),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("set_agent_mode_request: success emits accepted response carrying the notice", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const notice = { type: "info", message: "Switched to plan mode" } as const;
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
agentManager: { setAgentMode: vi.fn().mockResolvedValue(notice) },
|
||||
agentManager: liveAgentManager({ setAgentMode: vi.fn().mockResolvedValue(notice) }),
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
@@ -4588,7 +4943,9 @@ describe("agent config setters", () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
agentManager: { setAgentMode: vi.fn().mockRejectedValue(new Error("mode boom")) },
|
||||
agentManager: liveAgentManager({
|
||||
setAgentMode: vi.fn().mockRejectedValue(new Error("mode boom")),
|
||||
}),
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
@@ -4623,7 +4980,7 @@ describe("agent config setters", () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
agentManager: { setAgentModel: vi.fn().mockResolvedValue(undefined) },
|
||||
agentManager: liveAgentManager({ setAgentModel: vi.fn().mockResolvedValue(undefined) }),
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
@@ -4645,7 +5002,9 @@ describe("agent config setters", () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
agentManager: { setAgentModel: vi.fn().mockRejectedValue(new Error("model boom")) },
|
||||
agentManager: liveAgentManager({
|
||||
setAgentModel: vi.fn().mockRejectedValue(new Error("model boom")),
|
||||
}),
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
@@ -4680,7 +5039,7 @@ describe("agent config setters", () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
agentManager: { setAgentFeature: vi.fn().mockResolvedValue(undefined) },
|
||||
agentManager: liveAgentManager({ setAgentFeature: vi.fn().mockResolvedValue(undefined) }),
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
@@ -4703,7 +5062,9 @@ describe("agent config setters", () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
agentManager: { setAgentFeature: vi.fn().mockRejectedValue(new Error("feature boom")) },
|
||||
agentManager: liveAgentManager({
|
||||
setAgentFeature: vi.fn().mockRejectedValue(new Error("feature boom")),
|
||||
}),
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
@@ -4740,7 +5101,9 @@ describe("agent config setters", () => {
|
||||
const notice = { type: "warning", message: "Thinking budget reduced" } as const;
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
agentManager: { setAgentThinkingOption: vi.fn().mockResolvedValue(notice) },
|
||||
agentManager: liveAgentManager({
|
||||
setAgentThinkingOption: vi.fn().mockResolvedValue(notice),
|
||||
}),
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
@@ -4768,9 +5131,9 @@ describe("agent config setters", () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
agentManager: {
|
||||
agentManager: liveAgentManager({
|
||||
setAgentThinkingOption: vi.fn().mockRejectedValue(new Error("thinking boom")),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -182,6 +182,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
||||
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
scopes: ["*"],
|
||||
onMessage: (message) => emitted.push(message as { type: string; payload: unknown }),
|
||||
logger: createStub<pino.Logger>(logger),
|
||||
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
|
||||
@@ -472,7 +473,6 @@ describe("workspace git watch targets", () => {
|
||||
onBranchChanged: handleBranchChange,
|
||||
},
|
||||
);
|
||||
const sessionAny = session as unknown as SessionInternals;
|
||||
seedGitWorkspace({
|
||||
projects,
|
||||
workspaces,
|
||||
@@ -482,7 +482,7 @@ describe("workspace git watch targets", () => {
|
||||
name: "old-branch",
|
||||
});
|
||||
|
||||
syncGitObserver(session, "/tmp/repo", "ws-10");
|
||||
await session.syncWorkspaceGitObserversForExternalWorkspaceIds(["ws-10"]);
|
||||
|
||||
subscriptions[0]?.listener(
|
||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||
@@ -498,16 +498,6 @@ describe("workspace git watch targets", () => {
|
||||
scriptName: "app",
|
||||
}),
|
||||
]);
|
||||
expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([
|
||||
expect.objectContaining({
|
||||
scriptName: "app",
|
||||
hostname: "app--new-branch--paseo.localhost",
|
||||
localProxyUrl: "http://app--new-branch--paseo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://app--new-branch--paseo.localhost:6767",
|
||||
}),
|
||||
]);
|
||||
|
||||
await session.cleanup();
|
||||
});
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ function createHarness(input: {
|
||||
|
||||
const session = new Session({
|
||||
clientId: "test",
|
||||
scopes: ["*"],
|
||||
appVersion: null,
|
||||
onMessage: (m) => emitted.push(m),
|
||||
logger: createStub<SessionOptions["logger"]>(logger),
|
||||
@@ -112,6 +113,22 @@ function createHarness(input: {
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (id: string) => projects.get(id) ?? null,
|
||||
getOrCreateActiveByRoot: async (allocation) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) => !project.archivedAt && project.rootPath === allocation.rootPath,
|
||||
);
|
||||
if (existing) return existing;
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: `prj_${projects.size.toString().padStart(16, "0")}`,
|
||||
rootPath: allocation.rootPath,
|
||||
kind: allocation.kind,
|
||||
displayName: allocation.displayName,
|
||||
createdAt: allocation.timestamp,
|
||||
updatedAt: allocation.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
},
|
||||
upsert: async (record: PersistedProjectRecord) => {
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
@@ -310,10 +327,9 @@ test("S3: re-open active workspace by exact path returns the same record", async
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// S4. Open a subdir of an active git workspace: canonicalizes UP to the repo
|
||||
// root, returns the existing workspace. (Per "always go to the nearest git".)
|
||||
// S4. Every selected path is an exact lexical root, even inside a Git checkout.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S4: open subdir of active git workspace returns the repo-root workspace", async () => {
|
||||
test("S4: open subdir of active git workspace creates an independent exact-root workspace", async () => {
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace(FOO)],
|
||||
projects: [gitProject(FOO)],
|
||||
@@ -321,8 +337,9 @@ test("S4: open subdir of active git workspace returns the repo-root workspace",
|
||||
});
|
||||
await openProject(h.session, FOO_SUB);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, FOO)?.workspaceId);
|
||||
expect(h.workspaces.size).toBe(1);
|
||||
expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB);
|
||||
expect(resp?.workspace?.projectId).not.toBe(workspaceByCwd(h.workspaces, FOO)?.projectId);
|
||||
expect(h.workspaces.size).toBe(2);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -343,10 +360,10 @@ test("S5: open subdir of active non-git directory creates a SEPARATE workspace",
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// S6. Open the EXACT path of an archived git workspace: this IS explicit user
|
||||
// intent to re-open what they archived. Unarchive is correct here.
|
||||
// S6. Explicit project opening allocates a fresh identity when only archived
|
||||
// records exist. Agent restore is the separate path that restores ownership.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", async () => {
|
||||
test("S6: re-opening an archived git workspace by exact path creates a fresh project and workspace", async () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
@@ -354,8 +371,12 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy
|
||||
gitRoots: [TOOLBOX],
|
||||
});
|
||||
await openProject(h.session, TOOLBOX);
|
||||
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||
const fresh = Array.from(h.workspaces.values()).find(
|
||||
(workspace) => workspace.cwd === TOOLBOX && !workspace.archivedAt,
|
||||
);
|
||||
expect(fresh?.workspaceId).not.toBe("ws-toolbox");
|
||||
expect(fresh?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -433,11 +454,10 @@ test("S10: opening a git repo nested inside an archived non-git directory create
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// S11. Archive then re-add round-trip (project-level): opening the exact path
|
||||
// of an archived project unarchives both the project and its workspace,
|
||||
// reusing the same path-derived ids.
|
||||
// S11. Archive then re-add produces a fresh opaque identity; it never reuses
|
||||
// archived compatibility IDs.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S11: re-opening an archived project by exact path unarchives project + workspace and reuses ids", async () => {
|
||||
test("S11: re-opening an archived project by exact path keeps archived records and allocates fresh ids", async () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
@@ -447,12 +467,11 @@ test("S11: re-opening an archived project by exact path unarchives project + wor
|
||||
await openProject(h.session, TOOLBOX);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, TOOLBOX)?.workspaceId);
|
||||
expect(resp?.workspace?.projectId).toBe(TOOLBOX);
|
||||
expect(h.workspaces.size).toBe(1);
|
||||
expect(h.projects.size).toBe(1);
|
||||
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
|
||||
expect(resp?.workspace?.id).not.toBe("ws-toolbox");
|
||||
expect(resp?.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
expect(h.workspaces.size).toBe(2);
|
||||
expect(h.projects.size).toBe(2);
|
||||
expect(h.projects.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -482,7 +501,7 @@ test("S12: resolveWorkspaceIdForPath does not return archived ancestor via prefi
|
||||
// being explicit. To get git features back the user unarchives the parent
|
||||
// (S6/S11).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test("S13: subfolder of an archived git repo opens as a directory workspace", async () => {
|
||||
test("S13: subfolder of an archived git repo opens as its own git-backed workspace", async () => {
|
||||
const archivedAt = "2026-04-22T13:08:05.400Z";
|
||||
const h = createHarness({
|
||||
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
|
||||
@@ -492,5 +511,5 @@ test("S13: subfolder of an archived git repo opens as a directory workspace", as
|
||||
await openProject(h.session, TOOLBOX_FLOMO);
|
||||
const resp = getOpenResponse(h.emitted, "req-1");
|
||||
expect(resp?.error).toBeNull();
|
||||
expect(resp?.workspace?.workspaceKind).toBe("directory");
|
||||
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,14 +9,21 @@ import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js";
|
||||
import type { SessionOutboundMessage } from "../../messages.js";
|
||||
|
||||
class FakeAgentConfigOperations implements AgentConfigOperations {
|
||||
readonly loadedAgentIds: string[] = [];
|
||||
readonly modeCalls: Array<{ agentId: string; modeId: string }> = [];
|
||||
readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = [];
|
||||
readonly featureCalls: Array<{ agentId: string; featureId: string; value: unknown }> = [];
|
||||
readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = [];
|
||||
modeNotice: AgentProviderNotice | null = null;
|
||||
thinkingNotice: AgentProviderNotice | null = null;
|
||||
loadFailure: Error | null = null;
|
||||
failWith: Error | null = null;
|
||||
|
||||
async ensureLoaded(agentId: string): Promise<void> {
|
||||
this.loadedAgentIds.push(agentId);
|
||||
if (this.loadFailure) throw this.loadFailure;
|
||||
}
|
||||
|
||||
async setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
|
||||
this.modeCalls.push({ agentId, modeId });
|
||||
if (this.failWith) throw this.failWith;
|
||||
@@ -68,6 +75,7 @@ describe("AgentConfigSession", () => {
|
||||
});
|
||||
|
||||
expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
|
||||
expect(operations.loadedAgentIds).toEqual(["agent-1"]);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "set_agent_mode_response",
|
||||
@@ -109,6 +117,43 @@ describe("AgentConfigSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("set mode: a failed load rejects without mutating the collected agent", async () => {
|
||||
const { subsystem, emitted, operations } = makeSubsystem();
|
||||
operations.loadFailure = new Error("agent is archived");
|
||||
|
||||
await subsystem.handleSetAgentModeRequest({
|
||||
type: "set_agent_mode_request",
|
||||
agentId: "agent-1",
|
||||
modeId: "plan",
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(operations.loadedAgentIds).toEqual(["agent-1"]);
|
||||
expect(operations.modeCalls).toEqual([]);
|
||||
expect(emitted.map((message) => message.type)).toEqual([
|
||||
"activity_log",
|
||||
"set_agent_mode_response",
|
||||
]);
|
||||
expect(emitted[0]).toEqual({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: expect.any(String),
|
||||
timestamp: expect.any(Date),
|
||||
type: "error",
|
||||
content: "Failed to set agent mode: agent is archived",
|
||||
},
|
||||
});
|
||||
expect(emitted[1]).toEqual({
|
||||
type: "set_agent_mode_response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
agentId: "agent-1",
|
||||
accepted: false,
|
||||
error: "agent is archived",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("set model: emits an accepted response with no notice", async () => {
|
||||
const { subsystem, emitted, operations } = makeSubsystem();
|
||||
|
||||
|
||||
@@ -19,11 +19,12 @@ export interface AgentConfigSessionHost {
|
||||
|
||||
/**
|
||||
* The per-agent config mutations this subsystem drives. The shell adapts these
|
||||
* onto the live AgentManager (mode still routes through setAgentModeCommand);
|
||||
* tests wire an in-memory fake. Mode and thinking yield a provider notice; model
|
||||
* and feature do not.
|
||||
* onto the AgentManager and loads a collected agent before mutation (mode still
|
||||
* routes through setAgentModeCommand); tests wire an in-memory fake. Mode and
|
||||
* thinking yield a provider notice; model and feature do not.
|
||||
*/
|
||||
export interface AgentConfigOperations {
|
||||
ensureLoaded(agentId: string): Promise<void>;
|
||||
setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>;
|
||||
setModel(agentId: string, modelId: string | null): Promise<void>;
|
||||
setFeature(agentId: string, featureId: string, value: unknown): Promise<void>;
|
||||
@@ -138,6 +139,7 @@ export class AgentConfigSession {
|
||||
this.logger.info(logFields, `session: ${logLabel}`);
|
||||
|
||||
try {
|
||||
await this.operations.ensureLoaded(agentId);
|
||||
const notice = await run();
|
||||
this.logger.info(logFields, `session: ${logLabel} success`);
|
||||
emitResponse({ requestId, agentId, accepted: true, error: null, notice });
|
||||
|
||||
@@ -1220,7 +1220,8 @@ export class CheckoutSession {
|
||||
|
||||
try {
|
||||
// The payload schema keeps checkRunId and workflowRunId optional (a Gitea
|
||||
// Actions run has no check-run id; GitLab routes by changeRequestNumber),
|
||||
// Actions run has no check-run id; forge adapters may also route by
|
||||
// changeRequestNumber),
|
||||
// but a request that addresses no check at all is not actionable — reject
|
||||
// it here with a clear message instead of failing deep in an adapter. The
|
||||
// schema itself cannot enforce this: it is a discriminated-union member, so
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "./daemon-session.js";
|
||||
import type { DaemonWebSocketRuntimeDiagnosticSnapshot } from "./diagnostics.js";
|
||||
import type { ProviderAvailability } from "../../agent/agent-manager.js";
|
||||
import type { HubRelationshipManagement } from "../../hub/relationship-controller.js";
|
||||
import type { SessionOutboundMessage } from "../../messages.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -40,6 +41,7 @@ function makeSubsystem(overrides: {
|
||||
daemonRuntimeConfig?: DaemonRuntimeConfig;
|
||||
listProviderAvailability?: () => Promise<ProviderAvailability[]>;
|
||||
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
|
||||
hubRelationships?: HubRelationshipManagement;
|
||||
}) {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const restartIntents: Parameters<DaemonSessionHost["emitLifecycleIntent"]>[0][] = [];
|
||||
@@ -60,12 +62,67 @@ function makeSubsystem(overrides: {
|
||||
listWorkspaces: async () => [],
|
||||
listProviderAvailability: overrides.listProviderAvailability ?? (async () => []),
|
||||
getWebSocketRuntimeMetrics: overrides.getWebSocketRuntimeMetrics,
|
||||
hubRelationships: overrides.hubRelationships,
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
return { subsystem, emitted, paseoHome, restartIntents };
|
||||
}
|
||||
|
||||
describe("DaemonSession", () => {
|
||||
test("Hub relationship command failures return correlated RPC errors", async () => {
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
hubRelationships: {
|
||||
connect: async () => {
|
||||
throw new Error("Hub rejected enrollment (401)");
|
||||
},
|
||||
status: () => ({
|
||||
state: "not_connected",
|
||||
daemonId: null,
|
||||
hubOrigin: null,
|
||||
scopes: [],
|
||||
connectedAt: null,
|
||||
lastError: null,
|
||||
}),
|
||||
disconnect: async () => {
|
||||
throw new Error("Hub revocation failed (503)");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await subsystem.handleHubRelationshipRequest({
|
||||
type: "hub.management.daemon.connect.request",
|
||||
requestId: "connect-1",
|
||||
hubUrl: "https://hub.test",
|
||||
token: "token",
|
||||
});
|
||||
await subsystem.handleHubRelationshipRequest({
|
||||
type: "hub.management.daemon.disconnect.request",
|
||||
requestId: "disconnect-1",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: "connect-1",
|
||||
requestType: "hub.management.daemon.connect.request",
|
||||
error: "Hub rejected enrollment (401)",
|
||||
code: "handler_error",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: "disconnect-1",
|
||||
requestType: "hub.management.daemon.disconnect.request",
|
||||
error: "Hub revocation failed (503)",
|
||||
code: "handler_error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("status reports identity, runtime config, and providers with errors normalized to null", async () => {
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
serverId: "srv-1",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { DaemonSelfUpdateSessionController } from "./daemon-self-update-session-controller.js";
|
||||
import type { ManagedAgent } from "../../agent/agent-manager.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js";
|
||||
import type { HubRelationshipManagement } from "../../hub/relationship-controller.js";
|
||||
|
||||
export interface DaemonRuntimeConfig {
|
||||
listen: string | null;
|
||||
@@ -48,6 +49,7 @@ export interface DaemonSessionOptions {
|
||||
listProviderAvailability: () => Promise<ProviderAvailability[]>;
|
||||
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
|
||||
logger: pino.Logger;
|
||||
hubRelationships?: HubRelationshipManagement;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,6 +73,7 @@ export class DaemonSession {
|
||||
private readonly getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly selfUpdate: DaemonSelfUpdateSessionController;
|
||||
private readonly hubRelationships: HubRelationshipManagement | null;
|
||||
|
||||
constructor(options: DaemonSessionOptions) {
|
||||
this.host = options.host;
|
||||
@@ -85,6 +88,7 @@ export class DaemonSession {
|
||||
this.listProviderAvailability = options.listProviderAvailability;
|
||||
this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null);
|
||||
this.logger = options.logger;
|
||||
this.hubRelationships = options.hubRelationships ?? null;
|
||||
this.selfUpdate = new DaemonSelfUpdateSessionController({
|
||||
clientId: this.clientId,
|
||||
daemonVersion: this.daemonVersion ?? null,
|
||||
@@ -95,6 +99,56 @@ export class DaemonSession {
|
||||
});
|
||||
}
|
||||
|
||||
async handleHubRelationshipRequest(
|
||||
msg: Extract<
|
||||
SessionInboundMessage,
|
||||
{
|
||||
type:
|
||||
| "hub.management.daemon.connect.request"
|
||||
| "hub.management.daemon.get_status.request"
|
||||
| "hub.management.daemon.disconnect.request";
|
||||
}
|
||||
>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!this.hubRelationships) throw new Error("Hub relationship management is unavailable");
|
||||
if (msg.type === "hub.management.daemon.connect.request") {
|
||||
const status = await this.hubRelationships.connect({
|
||||
hubUrl: msg.hubUrl,
|
||||
token: msg.token,
|
||||
});
|
||||
this.host.emit({
|
||||
type: "hub.management.daemon.connect.response",
|
||||
payload: { requestId: msg.requestId, status },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (msg.type === "hub.management.daemon.disconnect.request") {
|
||||
const result = await this.hubRelationships.disconnect({ force: msg.force ?? false });
|
||||
this.host.emit({
|
||||
type: "hub.management.daemon.disconnect.response",
|
||||
payload: { requestId: msg.requestId, ...result },
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.host.emit({
|
||||
type: "hub.management.daemon.get_status.response",
|
||||
payload: { requestId: msg.requestId, status: this.hubRelationships.status() },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error({ err: error }, "Failed to handle Hub relationship request");
|
||||
this.host.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
requestType: msg.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
code: "handler_error",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleGetStatusRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "daemon.get_status.request" }>,
|
||||
): Promise<void> {
|
||||
|
||||
+74
-5
@@ -24,6 +24,7 @@ function makeDescriptor(overrides: {
|
||||
id: string;
|
||||
workspaceDirectory: string;
|
||||
projectKind?: string;
|
||||
workspaceKind?: "directory" | "local_checkout" | "worktree";
|
||||
name?: string | null;
|
||||
diffStat?: { additions: number; deletions: number } | null;
|
||||
}): WorkspaceDescriptorPayload {
|
||||
@@ -31,6 +32,7 @@ function makeDescriptor(overrides: {
|
||||
id: overrides.id,
|
||||
workspaceDirectory: overrides.workspaceDirectory,
|
||||
projectKind: overrides.projectKind ?? "git",
|
||||
workspaceKind: overrides.workspaceKind ?? "local_checkout",
|
||||
name: overrides.name ?? null,
|
||||
diffStat: overrides.diffStat ?? null,
|
||||
} as unknown as WorkspaceDescriptorPayload;
|
||||
@@ -135,11 +137,24 @@ describe("syncObservers", () => {
|
||||
test("does not register a non-git workspace", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "directory" }),
|
||||
makeDescriptor({
|
||||
id: "ws1",
|
||||
workspaceDirectory: WS1,
|
||||
projectKind: "directory",
|
||||
workspaceKind: "directory",
|
||||
}),
|
||||
]);
|
||||
expect(h.registerCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("registers a Git workspace even when its owning project is non-Git", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "non_git" }),
|
||||
]);
|
||||
expect(h.registerCalls).toEqual([WS1]);
|
||||
});
|
||||
|
||||
test("is idempotent — re-syncing the same git workspace does not re-register", () => {
|
||||
const h = buildHarness();
|
||||
const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1 });
|
||||
@@ -148,14 +163,46 @@ describe("syncObservers", () => {
|
||||
expect(h.registerCalls).toEqual([WS1]);
|
||||
});
|
||||
|
||||
test("shares one cwd subscription across distinct workspace identities", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1 }),
|
||||
makeDescriptor({ id: "ws2", workspaceDirectory: WS1 }),
|
||||
]);
|
||||
expect(h.registerCalls).toEqual([WS1]);
|
||||
});
|
||||
|
||||
test("tears down the subscription when a git workspace becomes non-git", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]);
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "directory" }),
|
||||
makeDescriptor({
|
||||
id: "ws1",
|
||||
workspaceDirectory: WS1,
|
||||
projectKind: "directory",
|
||||
workspaceKind: "directory",
|
||||
}),
|
||||
]);
|
||||
expect(h.unsubscribeCalls).toEqual([WS1]);
|
||||
});
|
||||
|
||||
test("keeps a shared subscription when one sibling becomes non-git", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1 }),
|
||||
makeDescriptor({ id: "ws2", workspaceDirectory: WS1 }),
|
||||
]);
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({
|
||||
id: "ws1",
|
||||
workspaceDirectory: WS1,
|
||||
workspaceKind: "directory",
|
||||
}),
|
||||
]);
|
||||
expect(h.unsubscribeCalls).toEqual([]);
|
||||
h.emitSnapshot(WS1, "feature");
|
||||
expect(h.branchChanges).toEqual([["ws2", null, "feature"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git snapshot listener", () => {
|
||||
@@ -202,6 +249,13 @@ describe("shouldSkipUpdate", () => {
|
||||
expect(h.service.shouldSkipUpdate("ws1", a)).toBe(true);
|
||||
expect(h.service.shouldSkipUpdate("ws1", b)).toBe(false);
|
||||
});
|
||||
|
||||
test("starts from the descriptor state recorded during observer sync", () => {
|
||||
const h = buildHarness();
|
||||
const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" });
|
||||
h.service.syncObservers([descriptor]);
|
||||
expect(h.service.shouldSkipUpdate("ws1", descriptor)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordDescriptorState", () => {
|
||||
@@ -239,10 +293,25 @@ describe("teardown", () => {
|
||||
expect(h.unsubscribeCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("removeForCwd unsubscribes and stops the observer", () => {
|
||||
test("keeps a shared cwd subscription until its last workspace is removed", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]);
|
||||
h.service.removeForCwd(WS1);
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }),
|
||||
makeDescriptor({ id: "ws2", workspaceDirectory: WS1, name: "main" }),
|
||||
]);
|
||||
|
||||
h.emitSnapshot(WS1, "feature");
|
||||
h.service.removeForWorkspaceId("ws1");
|
||||
expect(h.unsubscribeCalls).toEqual([]);
|
||||
|
||||
h.emitSnapshot(WS1, "next");
|
||||
expect(h.branchChanges).toEqual([
|
||||
["ws1", "main", "feature"],
|
||||
["ws2", "main", "feature"],
|
||||
["ws2", "feature", "next"],
|
||||
]);
|
||||
|
||||
h.service.removeForWorkspaceId("ws2");
|
||||
expect(h.unsubscribeCalls).toEqual([WS1]);
|
||||
expect(() => h.emitSnapshot(WS1, "x")).toThrow();
|
||||
});
|
||||
|
||||
+78
-55
@@ -10,8 +10,11 @@ import type { PersistedWorkspaceRecord } from "../../workspace-registry.js";
|
||||
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
|
||||
|
||||
interface WorkspaceGitWatchTarget {
|
||||
workspaceIds: Set<string>;
|
||||
}
|
||||
|
||||
interface WorkspaceGitWatchState {
|
||||
cwd: string;
|
||||
workspaceId: string;
|
||||
latestDescriptorStateKey: string | null;
|
||||
lastBranchName: string | null;
|
||||
}
|
||||
@@ -20,8 +23,9 @@ interface WorkspaceGitWatchTarget {
|
||||
* Observes a workspace's git state on disk (via WorkspaceGitService) and drives the
|
||||
* live update fan-out: branch-change notifications, workspace-card refreshes, and
|
||||
* checkout status updates. It owns the per-cwd watch targets and the WorkspaceGitService
|
||||
* subscription handles, so the registration / dedupe / teardown lifecycle lives in one
|
||||
* module instead of being smeared across the client session.
|
||||
* subscription handles. Filesystem subscriptions are keyed by cwd while descriptor and
|
||||
* branch state remain keyed by workspace id, so same-directory workspace records share one
|
||||
* watch without sharing identity or teardown lifetime.
|
||||
*
|
||||
* Branch changes reach `onBranchChanged` from two paths that share `lastBranchName`: the
|
||||
* on-disk snapshot listener (handleBranchSnapshot) and the workspace-emit loop
|
||||
@@ -37,7 +41,6 @@ export interface WorkspaceGitObserverService {
|
||||
recordDescriptorState(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): void;
|
||||
handleBranchSnapshot(cwd: string, branchName: string | null): void;
|
||||
removeForWorkspaceId(workspaceId: string): void;
|
||||
removeForCwd(cwd: string): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
@@ -67,6 +70,7 @@ export function createWorkspaceGitObserverService(deps: {
|
||||
} = deps;
|
||||
|
||||
const watchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
||||
const workspaceStates = new Map<string, WorkspaceGitWatchState>();
|
||||
const subscriptions = new Map<string, () => void>();
|
||||
|
||||
function descriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string {
|
||||
@@ -79,71 +83,93 @@ export function createWorkspaceGitObserverService(deps: {
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveTargetByWorkspaceId(workspaceId: string): WorkspaceGitWatchTarget | null {
|
||||
for (const target of watchTargets.values()) {
|
||||
if (target.workspaceId === workspaceId) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rememberDescriptorState(
|
||||
workspaceId: string,
|
||||
workspace: WorkspaceDescriptorPayload | null,
|
||||
): void {
|
||||
const target = resolveTargetByWorkspaceId(workspaceId);
|
||||
if (!target) {
|
||||
const state = workspaceStates.get(workspaceId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
target.latestDescriptorStateKey = descriptorStateKey(workspace);
|
||||
target.lastBranchName = workspace?.name ?? null;
|
||||
state.latestDescriptorStateKey = descriptorStateKey(workspace);
|
||||
state.lastBranchName = workspace?.name ?? null;
|
||||
}
|
||||
|
||||
function removeForCwd(cwd: string): void {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const target = watchTargets.get(normalizedCwd);
|
||||
for (const workspaceId of target?.workspaceIds ?? []) {
|
||||
workspaceStates.delete(workspaceId);
|
||||
}
|
||||
watchTargets.delete(normalizedCwd);
|
||||
subscriptions.get(normalizedCwd)?.();
|
||||
subscriptions.delete(normalizedCwd);
|
||||
}
|
||||
|
||||
function removeForWorkspaceId(workspaceId: string): void {
|
||||
const state = workspaceStates.get(workspaceId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
workspaceStates.delete(workspaceId);
|
||||
const target = watchTargets.get(state.cwd);
|
||||
target?.workspaceIds.delete(workspaceId);
|
||||
if (target?.workspaceIds.size === 0) {
|
||||
removeForCwd(state.cwd);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBranchSnapshot(cwd: string, branchName: string | null): void {
|
||||
const target = watchTargets.get(resolve(cwd));
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousBranchName = target.lastBranchName;
|
||||
if (branchName === previousBranchName) {
|
||||
return;
|
||||
for (const workspaceId of target.workspaceIds) {
|
||||
const state = workspaceStates.get(workspaceId);
|
||||
if (!state) {
|
||||
continue;
|
||||
}
|
||||
const previousBranchName = state.lastBranchName;
|
||||
if (branchName === previousBranchName) {
|
||||
continue;
|
||||
}
|
||||
state.lastBranchName = branchName;
|
||||
onBranchChanged?.(workspaceId, previousBranchName, branchName);
|
||||
}
|
||||
|
||||
target.lastBranchName = branchName;
|
||||
onBranchChanged?.(target.workspaceId, previousBranchName, branchName);
|
||||
}
|
||||
|
||||
function syncObserver(cwd: string, options: { isGit: boolean; workspaceId: string }): void {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const currentState = workspaceStates.get(options.workspaceId);
|
||||
if (currentState && currentState.cwd !== normalizedCwd) {
|
||||
removeForWorkspaceId(options.workspaceId);
|
||||
}
|
||||
if (!options.isGit) {
|
||||
removeForCwd(normalizedCwd);
|
||||
removeForWorkspaceId(options.workspaceId);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = watchTargets.get(normalizedCwd) ?? {
|
||||
workspaceIds: new Set<string>(),
|
||||
};
|
||||
watchTargets.set(normalizedCwd, target);
|
||||
target.workspaceIds.add(options.workspaceId);
|
||||
if (!workspaceStates.has(options.workspaceId)) {
|
||||
workspaceStates.set(options.workspaceId, {
|
||||
cwd: normalizedCwd,
|
||||
latestDescriptorStateKey: null,
|
||||
lastBranchName: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (subscriptions.has(normalizedCwd)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target: WorkspaceGitWatchTarget = {
|
||||
cwd: normalizedCwd,
|
||||
workspaceId: options.workspaceId,
|
||||
latestDescriptorStateKey: null,
|
||||
lastBranchName: null,
|
||||
};
|
||||
watchTargets.set(normalizedCwd, target);
|
||||
|
||||
const subscription = workspaceGitService.registerWorkspace(
|
||||
{ cwd: normalizedCwd },
|
||||
(snapshot) => {
|
||||
let subscription: ReturnType<WorkspaceGitService["registerWorkspace"]>;
|
||||
try {
|
||||
subscription = workspaceGitService.registerWorkspace({ cwd: normalizedCwd }, (snapshot) => {
|
||||
handleBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null);
|
||||
void emitWorkspaceUpdateForCwd(normalizedCwd).catch((error) => {
|
||||
logger.warn(
|
||||
@@ -152,18 +178,21 @@ export function createWorkspaceGitObserverService(deps: {
|
||||
);
|
||||
});
|
||||
emitStatusUpdate(normalizedCwd, snapshot);
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
removeForWorkspaceId(options.workspaceId);
|
||||
throw error;
|
||||
}
|
||||
subscriptions.set(normalizedCwd, subscription.unsubscribe);
|
||||
}
|
||||
|
||||
function syncObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
|
||||
for (const workspace of workspaces) {
|
||||
syncObserver(workspace.workspaceDirectory, {
|
||||
isGit: workspace.projectKind === "git",
|
||||
isGit: workspace.workspaceKind !== "directory",
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
rememberDescriptorState(workspace.workspaceDirectory, workspace);
|
||||
rememberDescriptorState(workspace.id, workspace);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,24 +211,24 @@ export function createWorkspaceGitObserverService(deps: {
|
||||
},
|
||||
|
||||
shouldSkipUpdate(workspaceId, workspace) {
|
||||
const target = resolveTargetByWorkspaceId(workspaceId);
|
||||
if (!target) {
|
||||
const state = workspaceStates.get(workspaceId);
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
const nextStateKey = descriptorStateKey(workspace);
|
||||
if (target.latestDescriptorStateKey === nextStateKey) {
|
||||
if (state.latestDescriptorStateKey === nextStateKey) {
|
||||
return true;
|
||||
}
|
||||
target.latestDescriptorStateKey = nextStateKey;
|
||||
state.latestDescriptorStateKey = nextStateKey;
|
||||
return false;
|
||||
},
|
||||
|
||||
recordDescriptorState(workspaceId, nextWorkspace) {
|
||||
const target = resolveTargetByWorkspaceId(workspaceId);
|
||||
if (target && onBranchChanged) {
|
||||
const state = workspaceStates.get(workspaceId);
|
||||
if (state && onBranchChanged) {
|
||||
const newBranchName = nextWorkspace?.name ?? null;
|
||||
if (newBranchName !== target.lastBranchName) {
|
||||
onBranchChanged(workspaceId, target.lastBranchName, newBranchName);
|
||||
if (newBranchName !== state.lastBranchName) {
|
||||
onBranchChanged(workspaceId, state.lastBranchName, newBranchName);
|
||||
}
|
||||
}
|
||||
rememberDescriptorState(workspaceId, nextWorkspace);
|
||||
@@ -207,14 +236,7 @@ export function createWorkspaceGitObserverService(deps: {
|
||||
|
||||
handleBranchSnapshot,
|
||||
|
||||
removeForWorkspaceId(workspaceId) {
|
||||
const target = resolveTargetByWorkspaceId(workspaceId);
|
||||
if (target) {
|
||||
removeForCwd(target.cwd);
|
||||
}
|
||||
},
|
||||
|
||||
removeForCwd,
|
||||
removeForWorkspaceId,
|
||||
|
||||
dispose() {
|
||||
for (const unsubscribe of subscriptions.values()) {
|
||||
@@ -222,6 +244,7 @@ export function createWorkspaceGitObserverService(deps: {
|
||||
}
|
||||
subscriptions.clear();
|
||||
watchTargets.clear();
|
||||
workspaceStates.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+276
-5
@@ -10,10 +10,13 @@ import {
|
||||
FileBackedProjectRegistry,
|
||||
FileBackedWorkspaceRegistry,
|
||||
type PersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
type WorkspaceRegistry,
|
||||
} from "../../workspace-registry.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
import {
|
||||
createWorkspaceProvisioningService,
|
||||
WorkspaceProvisioningError,
|
||||
type WorkspaceProvisioningService,
|
||||
} from "./workspace-provisioning-service.js";
|
||||
|
||||
@@ -27,6 +30,8 @@ const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
|
||||
|
||||
let tmpDir: string;
|
||||
let gitRoots: Set<string>;
|
||||
let gitBranches: Map<string, string | null>;
|
||||
let checkoutFailure: Error | null;
|
||||
let workspaceRegistry: FileBackedWorkspaceRegistry;
|
||||
let projectRegistry: FileBackedProjectRegistry;
|
||||
let provisioning: WorkspaceProvisioningService;
|
||||
@@ -35,6 +40,7 @@ function gitService() {
|
||||
return createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async (cwd: string) => {
|
||||
if (checkoutFailure) throw checkoutFailure;
|
||||
let worktreeRoot: string | null = null;
|
||||
for (const root of gitRoots) {
|
||||
if (
|
||||
@@ -47,7 +53,7 @@ function gitService() {
|
||||
return {
|
||||
cwd,
|
||||
isGit: worktreeRoot !== null,
|
||||
currentBranch: worktreeRoot ? "main" : null,
|
||||
currentBranch: worktreeRoot ? (gitBranches.get(worktreeRoot) ?? "main") : null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot,
|
||||
isPaseoOwnedWorktree: false,
|
||||
@@ -60,6 +66,8 @@ function gitService() {
|
||||
beforeEach(async () => {
|
||||
tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-provisioning-"));
|
||||
gitRoots = new Set();
|
||||
gitBranches = new Map();
|
||||
checkoutFailure = null;
|
||||
workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(tmpDir, "projects", "workspaces.json"),
|
||||
logger,
|
||||
@@ -112,6 +120,72 @@ test("re-opening an active workspace by exact path returns the same record witho
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("re-opening Windows-equivalent workspace cwd spellings reuses the active and archived record", async () => {
|
||||
const cwd = path.join(tmpDir, "workspace");
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(cwd);
|
||||
await workspaceRegistry.upsert({ ...created, cwd: `${cwd}${path.sep}` });
|
||||
|
||||
const active = await provisioning.findOrCreateWorkspaceForDirectory(cwd);
|
||||
expect(active.workspaceId).toBe(created.workspaceId);
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
|
||||
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
|
||||
const reopened = await provisioning.findOrCreateWorkspaceForDirectory(cwd);
|
||||
expect(reopened).toMatchObject({ workspaceId: created.workspaceId, archivedAt: null });
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("re-opening refreshes mutable checkout metadata without renaming the workspace", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
const first = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.upsert({ ...first, title: "Pinned work" });
|
||||
gitRoots.add(repo);
|
||||
gitBranches.set(repo, "feature/refresh");
|
||||
|
||||
const refreshed = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
|
||||
expect(refreshed).toMatchObject({
|
||||
workspaceId: first.workspaceId,
|
||||
kind: "local_checkout",
|
||||
branch: "feature/refresh",
|
||||
displayName: first.displayName,
|
||||
title: "Pinned work",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
expect(await workspaceRegistry.get(first.workspaceId)).toEqual(refreshed);
|
||||
expect((await projectRegistry.get(first.projectId))?.kind).toBe("git");
|
||||
});
|
||||
|
||||
test("persists manual worktree ownership separately from its workspace kind", async () => {
|
||||
const cwd = path.join(tmpDir, "manual-worktree");
|
||||
const mainRepoRoot = path.join(tmpDir, "main-repo");
|
||||
const manualWorktreeProvisioning = createWorkspaceProvisioningService({
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async () => ({
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "feature/manual",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const workspace = await manualWorktreeProvisioning.findOrCreateWorkspaceForDirectory(cwd);
|
||||
|
||||
expect(workspace).toMatchObject({
|
||||
kind: "worktree",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot,
|
||||
});
|
||||
});
|
||||
|
||||
test("re-opening an archived workspace by its exact path unarchives it and keeps the id", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
@@ -124,6 +198,110 @@ test("re-opening an archived workspace by its exact path unarchives it and keeps
|
||||
expect(reopened.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
test("reopening archived exact-root records restores the fresh Git project", async () => {
|
||||
const cwd = path.join(tmpDir, "repo");
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: cwd,
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
timestamp: ARCHIVED_AT,
|
||||
});
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-archived-root",
|
||||
projectId: project.projectId,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: ARCHIVED_AT,
|
||||
updatedAt: ARCHIVED_AT,
|
||||
archivedAt: ARCHIVED_AT,
|
||||
});
|
||||
await workspaceRegistry.upsert(workspace);
|
||||
await projectRegistry.archive(project.projectId, ARCHIVED_AT);
|
||||
const archivedProvisioning = createWorkspaceProvisioningService({
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async () => ({
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const reopened = await archivedProvisioning.ensureWorkspaceRecordUnarchived(workspace);
|
||||
|
||||
expect(reopened).toMatchObject({
|
||||
workspaceId: workspace.workspaceId,
|
||||
kind: "local_checkout",
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(await projectRegistry.get(project.projectId)).toMatchObject({
|
||||
kind: "git",
|
||||
archivedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("uses one workspace snapshot when reopening an archived workspace", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
|
||||
|
||||
const archived = (await workspaceRegistry.list()).filter(
|
||||
(workspace) => workspace.workspaceId === created.workspaceId,
|
||||
);
|
||||
let reads = 0;
|
||||
const snapshotRegistry: WorkspaceRegistry = {
|
||||
initialize: () => workspaceRegistry.initialize(),
|
||||
existsOnDisk: () => workspaceRegistry.existsOnDisk(),
|
||||
list: async () => (reads++ === 0 ? archived : []),
|
||||
get: (workspaceId) => workspaceRegistry.get(workspaceId),
|
||||
upsert: (workspace) => workspaceRegistry.upsert(workspace),
|
||||
archive: (workspaceId, archivedAt) => workspaceRegistry.archive(workspaceId, archivedAt),
|
||||
remove: (workspaceId) => workspaceRegistry.remove(workspaceId),
|
||||
};
|
||||
const snapshotProvisioning = createWorkspaceProvisioningService({
|
||||
workspaceRegistry: snapshotRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: gitService(),
|
||||
});
|
||||
|
||||
const reopened = await snapshotProvisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
|
||||
expect(reopened).toMatchObject({ workspaceId: created.workspaceId, archivedAt: null });
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("reopening an archived workspace refreshes placement without renaming it", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.upsert({ ...created, title: "Pinned archived work" });
|
||||
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
|
||||
gitRoots.delete(repo);
|
||||
|
||||
const reopened = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
|
||||
expect(reopened).toMatchObject({
|
||||
workspaceId: created.workspaceId,
|
||||
projectId: created.projectId,
|
||||
title: "Pinned archived work",
|
||||
kind: "directory",
|
||||
branch: null,
|
||||
displayName: created.displayName,
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(reopened.updatedAt).toEqual(expect.any(String));
|
||||
expect(await workspaceRegistry.get(created.workspaceId)).toEqual(reopened);
|
||||
});
|
||||
|
||||
test("opening a subpath of an archived git workspace mints a fresh workspace at the exact subpath", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
@@ -138,7 +316,7 @@ test("opening a subpath of an archived git workspace mints a fresh workspace at
|
||||
expect((await workspaceRegistry.get(canonical.workspaceId))?.archivedAt).toBe(ARCHIVED_AT);
|
||||
});
|
||||
|
||||
test("ensureWorkspaceRecordUnarchived clears archivedAt on the workspace and its project", async () => {
|
||||
test("ensureWorkspaceRecordUnarchived restores the owning archived project with the workspace", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
@@ -154,6 +332,24 @@ test("ensureWorkspaceRecordUnarchived clears archivedAt on the workspace and its
|
||||
expect((await projectRegistry.get(created.projectId))?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
test("does not unarchive either record when checkout refresh fails", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await projectRegistry.archive(created.projectId, ARCHIVED_AT);
|
||||
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
|
||||
const archivedProject = await projectRegistry.get(created.projectId);
|
||||
const archivedWorkspace = await workspaceRegistry.get(created.workspaceId);
|
||||
checkoutFailure = new Error("Git read failed");
|
||||
|
||||
await expect(provisioning.ensureWorkspaceRecordUnarchived(archivedWorkspace!)).rejects.toThrow(
|
||||
"Git read failed",
|
||||
);
|
||||
|
||||
expect(await projectRegistry.get(created.projectId)).toEqual(archivedProject);
|
||||
expect(await workspaceRegistry.get(created.workspaceId)).toEqual(archivedWorkspace);
|
||||
});
|
||||
|
||||
test("resolveOrCreateWorkspaceIdForCreateAgent returns a created worktree's id without touching the registry", async () => {
|
||||
// The branch only reads workspace.workspaceId off the worktree result.
|
||||
const createdWorktree = {
|
||||
@@ -207,15 +403,90 @@ test("createWorkspaceForDirectory always mints a fresh workspace even when one a
|
||||
expect(await workspaceRegistry.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("findOrCreateProjectForDirectory reuses the active project for the same root", async () => {
|
||||
test("directory creation persists the live branch and a trimmed title", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
const workspace = await provisioning.createWorkspaceForDirectory(repo, " Focused work ");
|
||||
expect(workspace).toMatchObject({ branch: "main", title: "Focused work" });
|
||||
});
|
||||
|
||||
test("createWorkspaceForDirectory honors an explicit active project without cwd containment", async () => {
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: path.join(tmpDir, "elsewhere"),
|
||||
kind: "non_git",
|
||||
displayName: "elsewhere",
|
||||
timestamp: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
const workspace = await provisioning.createWorkspaceForDirectory(
|
||||
path.join(tmpDir, "directory"),
|
||||
null,
|
||||
project.projectId,
|
||||
);
|
||||
expect(workspace.projectId).toBe(project.projectId);
|
||||
});
|
||||
|
||||
test("createWorkspaceForDirectory refreshes an explicit project's stale Git kind", async () => {
|
||||
const rootPath = path.join(tmpDir, "repo");
|
||||
gitRoots.add(rootPath);
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath,
|
||||
kind: "non_git",
|
||||
displayName: "Saved project name",
|
||||
timestamp: ARCHIVED_AT,
|
||||
});
|
||||
await projectRegistry.upsert({ ...project, customName: "Pinned project name" });
|
||||
|
||||
const workspace = await provisioning.createWorkspaceForDirectory(
|
||||
rootPath,
|
||||
null,
|
||||
project.projectId,
|
||||
);
|
||||
|
||||
expect(workspace.projectId).toBe(project.projectId);
|
||||
expect(await projectRegistry.get(project.projectId)).toMatchObject({
|
||||
projectId: project.projectId,
|
||||
rootPath,
|
||||
kind: "git",
|
||||
displayName: "Saved project name",
|
||||
customName: "Pinned project name",
|
||||
});
|
||||
});
|
||||
|
||||
test("createWorkspaceForDirectory classifies unknown and archived explicit projects", async () => {
|
||||
await expect(
|
||||
provisioning.createWorkspaceForDirectory(path.join(tmpDir, "directory"), null, "missing"),
|
||||
).rejects.toMatchObject({
|
||||
code: "unknown_project",
|
||||
} satisfies Partial<WorkspaceProvisioningError>);
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: path.join(tmpDir, "archived"),
|
||||
kind: "non_git",
|
||||
displayName: "archived",
|
||||
timestamp: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
await projectRegistry.archive(project.projectId, "2026-03-02T00:00:00.000Z");
|
||||
await expect(
|
||||
provisioning.createWorkspaceForDirectory(
|
||||
path.join(tmpDir, "directory"),
|
||||
null,
|
||||
project.projectId,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: "archived_project",
|
||||
} satisfies Partial<WorkspaceProvisioningError>);
|
||||
});
|
||||
|
||||
test("findOrCreateProjectForDirectory keeps nested selected roots independent", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
gitRoots.add(repo);
|
||||
|
||||
const first = await provisioning.findOrCreateProjectForDirectory(repo);
|
||||
const second = await provisioning.findOrCreateProjectForDirectory(path.join(repo, "sub"));
|
||||
|
||||
expect(second.projectId).toBe(first.projectId);
|
||||
expect(await projectRegistry.list()).toHaveLength(1);
|
||||
expect(second.projectId).not.toBe(first.projectId);
|
||||
expect(first.rootPath).toBe(repo);
|
||||
expect(second.rootPath).toBe(path.join(repo, "sub"));
|
||||
expect(await projectRegistry.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("runInImportWorkspace uses an active requested workspace without creating another", async () => {
|
||||
|
||||
+222
-207
@@ -1,12 +1,11 @@
|
||||
import { resolve } from "node:path";
|
||||
import { basename, resolve } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import {
|
||||
checkoutLiteFromGitSnapshot,
|
||||
classifyDirectoryForProjectMembership,
|
||||
generateWorkspaceId,
|
||||
initialWorkspacePlacement,
|
||||
reconcileWorkspacePlacement,
|
||||
} from "../../workspace-registry-model.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
@@ -15,20 +14,8 @@ import {
|
||||
} from "../../workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
|
||||
/**
|
||||
* Resolves which workspace and project records a directory belongs to, creating,
|
||||
* reclassifying, or unarchiving them as needed. Every path that needs a workspace
|
||||
* for a cwd — opening a project, importing an agent, creating an agent, restoring
|
||||
* an archived worktree — funnels through this one module, so the
|
||||
* classify → resolve-project → persist → unarchive sequence (and the
|
||||
* archived-reopen-at-a-different-path and reclassify-vs-unarchive special cases)
|
||||
* lives in a single place instead of being smeared across the session.
|
||||
*
|
||||
* Read-only path resolution (no create/persist) lives in resolve-workspace-id-for-path.ts;
|
||||
* this module owns the create-and-persist side.
|
||||
*/
|
||||
export interface ResolveOrCreateWorkspaceIdInput {
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
||||
requestedWorkspaceId?: string;
|
||||
@@ -46,6 +33,17 @@ export interface ImportWorkspaceResult<T> {
|
||||
createdWorkspace: PersistedWorkspaceRecord | null;
|
||||
}
|
||||
|
||||
export interface CreateWorktreeWorkspaceInput {
|
||||
sourceCwd: string;
|
||||
projectId?: string;
|
||||
repoRoot: string;
|
||||
cwd: string;
|
||||
worktreeRoot: string;
|
||||
branch: string | null;
|
||||
baseBranch: string | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceProvisioningService {
|
||||
runInImportWorkspace<T>(
|
||||
input: ImportWorkspaceInput,
|
||||
@@ -56,6 +54,10 @@ export interface WorkspaceProvisioningService {
|
||||
createWorkspaceForDirectory(
|
||||
cwd: string,
|
||||
title?: string | null,
|
||||
projectId?: string,
|
||||
): Promise<PersistedWorkspaceRecord>;
|
||||
createWorkspaceForWorktree(
|
||||
input: CreateWorktreeWorkspaceInput,
|
||||
): Promise<PersistedWorkspaceRecord>;
|
||||
findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord>;
|
||||
ensureWorkspaceRecordUnarchived(
|
||||
@@ -63,6 +65,22 @@ export interface WorkspaceProvisioningService {
|
||||
): Promise<PersistedWorkspaceRecord>;
|
||||
}
|
||||
|
||||
export type WorkspaceProvisioningErrorCode = "unknown_project" | "archived_project";
|
||||
|
||||
export class WorkspaceProvisioningError extends Error {
|
||||
constructor(
|
||||
readonly code: WorkspaceProvisioningErrorCode,
|
||||
projectId: string,
|
||||
) {
|
||||
super(
|
||||
code === "unknown_project"
|
||||
? `Unknown project: ${projectId}`
|
||||
: `Archived project: ${projectId}`,
|
||||
);
|
||||
this.name = "WorkspaceProvisioningError";
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorkspaceProvisioningService(deps: {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
projectRegistry: ProjectRegistry;
|
||||
@@ -134,224 +152,220 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveWorkspaceDirectory(
|
||||
cwd: string,
|
||||
options?: { refreshGit?: boolean },
|
||||
): Promise<string> {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
if (options?.refreshGit === false) {
|
||||
const snapshot = workspaceGitService.peekSnapshot(normalizedCwd);
|
||||
return resolve(snapshot?.git.repoRoot ?? normalizedCwd);
|
||||
}
|
||||
|
||||
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
|
||||
return resolve(checkout.worktreeRoot ?? normalizedCwd);
|
||||
}
|
||||
|
||||
async function findExactWorkspaceByDirectory(
|
||||
cwd: string,
|
||||
options?: { refreshGit?: boolean },
|
||||
): Promise<PersistedWorkspaceRecord | null> {
|
||||
const normalizedCwd = await resolveWorkspaceDirectory(cwd, options);
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null;
|
||||
}
|
||||
|
||||
async function resolveProjectRecordForPlacement(input: {
|
||||
membership: ReturnType<typeof classifyDirectoryForProjectMembership>;
|
||||
timestamp: string;
|
||||
}): Promise<PersistedProjectRecord> {
|
||||
const rootPath = input.membership.projectRootPath;
|
||||
const kind = input.membership.projectKind;
|
||||
const projects = await projectRegistry.list();
|
||||
const existingProject =
|
||||
projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ??
|
||||
projects.find((project) => project.rootPath === rootPath) ??
|
||||
null;
|
||||
|
||||
if (!existingProject) {
|
||||
return createPersistedProjectRecord({
|
||||
projectId: input.membership.projectKey,
|
||||
rootPath,
|
||||
kind,
|
||||
displayName: input.membership.projectName,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...existingProject,
|
||||
rootPath,
|
||||
kind,
|
||||
archivedAt: null,
|
||||
updatedAt: input.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
async function reclassifyOrUnarchiveWorkspaceForDirectory(input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
project: PersistedProjectRecord | null;
|
||||
cwd: string;
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const checkout = await workspaceGitService.getCheckout(input.cwd);
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd: input.cwd, checkout });
|
||||
async function findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord> {
|
||||
const rootPath = resolve(cwd);
|
||||
const checkout = await workspaceGitService.getCheckout(rootPath);
|
||||
const timestamp = new Date().toISOString();
|
||||
const projectRecord = await resolveProjectRecordForPlacement({
|
||||
membership,
|
||||
return projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath,
|
||||
kind: checkout.isGit ? "git" : "non_git",
|
||||
displayName: basename(rootPath) || rootPath,
|
||||
timestamp,
|
||||
});
|
||||
const projectId = projectRecord.projectId;
|
||||
const kind = membership.workspaceKind;
|
||||
const displayName = membership.workspaceDisplayName;
|
||||
}
|
||||
|
||||
if (
|
||||
input.workspace.projectId === projectId &&
|
||||
input.workspace.kind === kind &&
|
||||
input.workspace.displayName === displayName
|
||||
) {
|
||||
if (!input.project) {
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
}
|
||||
return ensureWorkspaceRecordUnarchived(input.workspace);
|
||||
async function requireActiveProject(projectId: string): Promise<PersistedProjectRecord> {
|
||||
const project = await projectRegistry.get(projectId);
|
||||
if (!project) throw new WorkspaceProvisioningError("unknown_project", projectId);
|
||||
if (project.archivedAt) throw new WorkspaceProvisioningError("archived_project", projectId);
|
||||
return project;
|
||||
}
|
||||
|
||||
async function createWorkspaceForDirectory(
|
||||
cwd: string,
|
||||
title?: string | null,
|
||||
projectId?: string,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
|
||||
const project = projectId
|
||||
? await refreshProjectKind(await requireActiveProject(projectId), normalizedCwd, checkout)
|
||||
: // COMPAT(workspaceCreateMissingProjectId): added in v0.1.107, remove after 2027-01-15.
|
||||
await findOrCreateProjectForDirectory(normalizedCwd);
|
||||
const timestamp = new Date().toISOString();
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: project.projectId,
|
||||
...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }),
|
||||
title: title?.trim() || null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await workspaceRegistry.upsert(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function createWorkspaceForWorktree(
|
||||
input: CreateWorktreeWorkspaceInput,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const sourceCwd = resolve(input.sourceCwd);
|
||||
const repoRoot = resolve(input.repoRoot);
|
||||
const cwd = resolve(input.cwd);
|
||||
const worktreeRoot = resolve(input.worktreeRoot);
|
||||
const project = await resolveSourceProjectForWorktree({
|
||||
sourceCwd,
|
||||
projectId: input.projectId,
|
||||
repoRoot,
|
||||
});
|
||||
const timestamp = new Date().toISOString();
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: project.projectId,
|
||||
...initialWorkspacePlacement({
|
||||
source: "created_worktree",
|
||||
cwd,
|
||||
worktreeRoot,
|
||||
branch: input.branch,
|
||||
baseBranch: input.baseBranch,
|
||||
mainRepoRoot: repoRoot,
|
||||
}),
|
||||
title: input.title,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await workspaceRegistry.upsert(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function resolveSourceProjectForWorktree(input: {
|
||||
sourceCwd: string;
|
||||
projectId?: string;
|
||||
repoRoot: string;
|
||||
}): Promise<PersistedProjectRecord> {
|
||||
if (input.projectId) {
|
||||
return refreshProjectKind(await requireActiveProject(input.projectId));
|
||||
}
|
||||
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
const sourceWorkspace =
|
||||
workspaces.find(
|
||||
(workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, input.sourceCwd),
|
||||
) ??
|
||||
workspaces.find(
|
||||
(workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, input.repoRoot),
|
||||
);
|
||||
if (sourceWorkspace) {
|
||||
const project = await projectRegistry.get(sourceWorkspace.projectId);
|
||||
if (project) return refreshProjectKind(project);
|
||||
// COMPAT(worktreeMissingSourceProject): added in v0.1.107, remove after 2027-01-15.
|
||||
// Orphaned legacy workspace FKs fall through to exact-root allocation.
|
||||
}
|
||||
|
||||
const nextWorkspace = {
|
||||
...input.workspace,
|
||||
projectId,
|
||||
cwd: input.cwd,
|
||||
kind,
|
||||
displayName,
|
||||
archivedAt: null,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
await workspaceRegistry.upsert(nextWorkspace);
|
||||
return nextWorkspace;
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: input.repoRoot,
|
||||
kind: "git",
|
||||
displayName: basename(input.repoRoot) || input.repoRoot,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return refreshProjectKind(project);
|
||||
}
|
||||
|
||||
async function findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
|
||||
const inputCwd = resolve(cwd);
|
||||
const normalizedCwd = await resolveWorkspaceDirectory(cwd);
|
||||
const existingWorkspace = await findExactWorkspaceByDirectory(normalizedCwd, {
|
||||
refreshGit: false,
|
||||
});
|
||||
if (existingWorkspace) {
|
||||
if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const checkout = checkoutLiteFromGitSnapshot(inputCwd, {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
repoRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd: inputCwd, checkout });
|
||||
const projectRecord = await resolveProjectRecordForPlacement({
|
||||
membership,
|
||||
timestamp,
|
||||
});
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
const workspaceRecord = createPersistedWorkspaceRecord({
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: projectRecord.projectId,
|
||||
cwd: inputCwd,
|
||||
kind: membership.workspaceKind,
|
||||
displayName: membership.workspaceDisplayName,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await workspaceRegistry.upsert(workspaceRecord);
|
||||
return workspaceRecord;
|
||||
}
|
||||
return reclassifyOrUnarchiveWorkspaceForDirectory({
|
||||
workspace: existingWorkspace,
|
||||
project: await projectRegistry.get(existingWorkspace.projectId),
|
||||
cwd: normalizedCwd,
|
||||
});
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
const active = workspaces
|
||||
.filter(
|
||||
(workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
|
||||
left.workspaceId.localeCompare(right.workspaceId),
|
||||
)[0];
|
||||
if (active) return refreshWorkspaceRecord(active);
|
||||
const archived = workspaces
|
||||
.filter(
|
||||
(workspace) => workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
|
||||
left.workspaceId.localeCompare(right.workspaceId),
|
||||
)[0];
|
||||
if (archived) {
|
||||
const project = await projectRegistry.get(archived.projectId);
|
||||
if (project && !project.archivedAt) return ensureWorkspaceRecordUnarchived(archived);
|
||||
}
|
||||
|
||||
return createWorkspaceForDirectory(normalizedCwd);
|
||||
}
|
||||
|
||||
async function resolveOrCreateWorkspaceIdForCreateAgent(
|
||||
input: ResolveOrCreateWorkspaceIdInput,
|
||||
): Promise<string> {
|
||||
if (input.createdWorktree) {
|
||||
return input.createdWorktree.workspace.workspaceId;
|
||||
}
|
||||
|
||||
if (input.requestedWorkspaceId) {
|
||||
return input.requestedWorkspaceId;
|
||||
}
|
||||
|
||||
if (input.createdWorktree) return input.createdWorktree.workspace.workspaceId;
|
||||
if (input.requestedWorkspaceId) return input.requestedWorkspaceId;
|
||||
return (await createWorkspaceForDirectory(input.cwd, input.initialTitle)).workspaceId;
|
||||
}
|
||||
|
||||
async function createWorkspaceForDirectory(
|
||||
cwd: string,
|
||||
title?: string | null,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const checkout = await workspaceGitService.getCheckout(cwd);
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd, checkout });
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const projectRecord = await resolveProjectRecordForPlacement({
|
||||
membership,
|
||||
timestamp,
|
||||
});
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
|
||||
const workspaceRecord = createPersistedWorkspaceRecord({
|
||||
workspaceId: generateWorkspaceId(),
|
||||
projectId: projectRecord.projectId,
|
||||
cwd,
|
||||
kind: membership.workspaceKind,
|
||||
displayName: membership.workspaceDisplayName,
|
||||
title: title ?? null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await workspaceRegistry.upsert(workspaceRecord);
|
||||
return workspaceRecord;
|
||||
}
|
||||
|
||||
async function findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord> {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
|
||||
const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout });
|
||||
const projectRecord = await resolveProjectRecordForPlacement({
|
||||
membership,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await projectRegistry.upsert(projectRecord);
|
||||
return projectRecord;
|
||||
}
|
||||
|
||||
async function ensureWorkspaceRecordUnarchived(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
if (!workspace.archivedAt && (!project || !project.archivedAt)) {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
if (!project) throw new Error(`Unknown project: ${workspace.projectId}`);
|
||||
const timestamp = new Date().toISOString();
|
||||
let unarchivedWorkspace = workspace;
|
||||
if (workspace.archivedAt) {
|
||||
unarchivedWorkspace = { ...workspace, archivedAt: null, updatedAt: timestamp };
|
||||
await workspaceRegistry.upsert(unarchivedWorkspace);
|
||||
}
|
||||
if (project?.archivedAt) {
|
||||
await projectRegistry.upsert({
|
||||
...project,
|
||||
archivedAt: null,
|
||||
const checkout =
|
||||
workspace.archivedAt || project.archivedAt
|
||||
? await workspaceGitService.getCheckout(workspace.cwd)
|
||||
: null;
|
||||
let next: PersistedWorkspaceRecord | null = null;
|
||||
if (workspace.archivedAt && checkout) {
|
||||
const placementUpdate = reconcileWorkspacePlacement({
|
||||
workspace,
|
||||
checkout,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
next = {
|
||||
...(placementUpdate?.workspace ?? workspace),
|
||||
archivedAt: null,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
return unarchivedWorkspace;
|
||||
if (checkout && (project.archivedAt || workspace.archivedAt)) {
|
||||
const projectCheckout = areEquivalentPaths(project.rootPath, workspace.cwd)
|
||||
? checkout
|
||||
: await workspaceGitService.getCheckout(project.rootPath);
|
||||
const kind = projectCheckout.isGit ? "git" : "non_git";
|
||||
if (project.archivedAt || project.kind !== kind) {
|
||||
await projectRegistry.upsert({ ...project, kind, archivedAt: null, updatedAt: timestamp });
|
||||
}
|
||||
}
|
||||
if (!next) return workspace;
|
||||
await workspaceRegistry.upsert(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function refreshWorkspaceRecord(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const checkout = await workspaceGitService.getCheckout(workspace.cwd);
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
if (project && !project.archivedAt) {
|
||||
await refreshProjectKind(project, workspace.cwd, checkout);
|
||||
}
|
||||
const update = reconcileWorkspacePlacement({
|
||||
workspace,
|
||||
checkout,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
if (!update) return workspace;
|
||||
await workspaceRegistry.upsert(update.workspace);
|
||||
return update.workspace;
|
||||
}
|
||||
|
||||
async function refreshProjectKind(
|
||||
project: PersistedProjectRecord,
|
||||
workspaceCwd?: string,
|
||||
workspaceCheckout?: Awaited<ReturnType<WorkspaceGitService["getCheckout"]>>,
|
||||
): Promise<PersistedProjectRecord> {
|
||||
const projectCheckout =
|
||||
workspaceCwd && workspaceCheckout && areEquivalentPaths(project.rootPath, workspaceCwd)
|
||||
? workspaceCheckout
|
||||
: await workspaceGitService.getCheckout(project.rootPath);
|
||||
const kind: PersistedProjectRecord["kind"] = projectCheckout.isGit ? "git" : "non_git";
|
||||
if (project.kind === kind) return project;
|
||||
const refreshed = { ...project, kind, updatedAt: new Date().toISOString() };
|
||||
await projectRegistry.upsert(refreshed);
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -359,6 +373,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
findOrCreateWorkspaceForDirectory,
|
||||
resolveOrCreateWorkspaceIdForCreateAgent,
|
||||
createWorkspaceForDirectory,
|
||||
createWorkspaceForWorktree,
|
||||
findOrCreateProjectForDirectory,
|
||||
ensureWorkspaceRecordUnarchived,
|
||||
};
|
||||
|
||||
+197
-40
@@ -1,4 +1,19 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createWorktree } from "../../../utils/worktree.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
@@ -8,15 +23,23 @@ import {
|
||||
import { createWorkspaceRecoveryService } from "./workspace-recovery-service.js";
|
||||
|
||||
const NOW = "2026-07-11T10:12:30.752Z";
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
function createProject(): PersistedProjectRecord {
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createProject(overrides: Partial<PersistedProjectRecord> = {}): PersistedProjectRecord {
|
||||
return createPersistedProjectRecord({
|
||||
projectId: "/repo",
|
||||
rootPath: "/repo",
|
||||
projectId: "/project",
|
||||
rootPath: "/project",
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
displayName: "project",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,12 +48,15 @@ function createWorkspace(
|
||||
): PersistedWorkspaceRecord {
|
||||
return createPersistedWorkspaceRecord({
|
||||
workspaceId: "wks_15a1b5630ebaab33",
|
||||
projectId: "/repo",
|
||||
projectId: "/project",
|
||||
cwd: "/worktrees/trigger-1525443412986298439",
|
||||
kind: "worktree",
|
||||
displayName: "diagnose-repro-tdd",
|
||||
title: "Codex TDD reproduction",
|
||||
title: "TDD reproduction",
|
||||
branch: "diagnose-repro-tdd",
|
||||
worktreeRoot: "/worktrees/trigger-1525443412986298439",
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/repo",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: NOW,
|
||||
@@ -42,55 +68,53 @@ function createHarness(input?: {
|
||||
workspace?: PersistedWorkspaceRecord | null;
|
||||
project?: PersistedProjectRecord | null;
|
||||
directories?: string[];
|
||||
recreate?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
}) {
|
||||
const workspace = input?.workspace === undefined ? createWorkspace() : input.workspace;
|
||||
const project = input?.project === undefined ? createProject() : input.project;
|
||||
const directories = new Set(input?.directories ?? ["/repo"]);
|
||||
const unarchived: string[] = [];
|
||||
const recreated: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
paseoHome: input?.paseoHome ?? "/paseo-home",
|
||||
worktreesRoot: input?.worktreesRoot ?? "/worktrees",
|
||||
getWorkspace: async (workspaceId) =>
|
||||
workspace?.workspaceId === workspaceId ? workspace : null,
|
||||
getProject: async (projectId) => (project?.projectId === projectId ? project : null),
|
||||
isDirectory: async (path) => directories.has(path),
|
||||
recreateWorktree: async (record) => {
|
||||
recreated.push(record.workspaceId);
|
||||
await input?.recreate?.(record);
|
||||
},
|
||||
unarchiveWorkspace: async (record) => {
|
||||
unarchived.push(record.workspaceId);
|
||||
},
|
||||
});
|
||||
return { service, recreated, unarchived };
|
||||
return { service, unarchived };
|
||||
}
|
||||
|
||||
describe("workspace recovery", () => {
|
||||
test("authoritatively describes the archived missing worktree from the failed cloud run", async () => {
|
||||
const { service, recreated, unarchived } = createHarness();
|
||||
test("describes a missing archived worktree from persisted placement", async () => {
|
||||
const { service, unarchived } = createHarness();
|
||||
|
||||
await expect(service.inspect("wks_15a1b5630ebaab33")).resolves.toEqual({
|
||||
kind: "recoverable",
|
||||
workspaceId: "wks_15a1b5630ebaab33",
|
||||
workspaceName: "Codex TDD reproduction",
|
||||
workspaceName: "TDD reproduction",
|
||||
action: "restore",
|
||||
branch: "diagnose-repro-tdd",
|
||||
});
|
||||
expect(recreated).toEqual([]);
|
||||
expect(unarchived).toEqual([]);
|
||||
});
|
||||
|
||||
test("describes an archived workspace whose directory remains as unarchivable", async () => {
|
||||
test("unarchives an archived workspace whose exact directory remains", async () => {
|
||||
const workspace = createWorkspace({ kind: "directory", branch: null });
|
||||
const { service } = createHarness({
|
||||
const { service, unarchived } = createHarness({
|
||||
workspace,
|
||||
directories: ["/repo", workspace.cwd],
|
||||
directories: [workspace.cwd],
|
||||
});
|
||||
|
||||
await expect(service.inspect(workspace.workspaceId)).resolves.toMatchObject({
|
||||
kind: "recoverable",
|
||||
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
|
||||
workspaceId: workspace.workspaceId,
|
||||
action: "unarchive",
|
||||
});
|
||||
expect(unarchived).toEqual([workspace.workspaceId]);
|
||||
});
|
||||
|
||||
test("does not offer recovery for a missing non-worktree directory", async () => {
|
||||
@@ -105,27 +129,160 @@ describe("workspace recovery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the workspace archived when recreation fails so restore can be retried", async () => {
|
||||
let attempts = 0;
|
||||
const { service, recreated, unarchived } = createHarness({
|
||||
recreate: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error("git branch diagnose-repro-tdd is unavailable");
|
||||
}
|
||||
test("uses the persisted source repository instead of the owning project to restore an exact subdirectory", async () => {
|
||||
const { tempDir, repoDir } = createGitRepository();
|
||||
const branch = "feature/mixed-project";
|
||||
const sourceSubdirectory = join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceSubdirectory, { recursive: true });
|
||||
writeFileSync(join(sourceSubdirectory, "README.md"), "app\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const paseoHome = join(tempDir, "paseo-home");
|
||||
const worktreesRoot = join(tempDir, "worktrees");
|
||||
const created = await createWorktree({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "mixed-project",
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
});
|
||||
const worktreeRoot = realpathSync(created.worktreePath);
|
||||
const workspaceCwd = join(worktreeRoot, "packages", "app");
|
||||
rmSync(worktreeRoot, { recursive: true, force: true });
|
||||
execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const projectRoot = join(tempDir, "explicit-non-git-project");
|
||||
mkdirSync(projectRoot);
|
||||
const project = createProject({
|
||||
projectId: "explicit-non-git-project",
|
||||
rootPath: projectRoot,
|
||||
kind: "non_git",
|
||||
});
|
||||
const workspace = createWorkspace({
|
||||
workspaceId: "ws-mixed-project-recreate",
|
||||
projectId: project.projectId,
|
||||
cwd: workspaceCwd,
|
||||
branch,
|
||||
worktreeRoot,
|
||||
mainRepoRoot: repoDir,
|
||||
});
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async (workspaceId) =>
|
||||
workspaceId === workspace.workspaceId ? workspace : null,
|
||||
getProject: async (projectId) => (projectId === project.projectId ? project : null),
|
||||
isDirectory: async (path) => existsSync(path) && statSync(path).isDirectory(),
|
||||
unarchiveWorkspace: async (record) => {
|
||||
unarchived.push(record.workspaceId);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.restore("wks_15a1b5630ebaab33")).rejects.toThrow(
|
||||
"git branch diagnose-repro-tdd is unavailable",
|
||||
);
|
||||
expect(unarchived).toEqual([]);
|
||||
|
||||
await expect(service.restore("wks_15a1b5630ebaab33")).resolves.toEqual({
|
||||
workspaceId: "wks_15a1b5630ebaab33",
|
||||
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
|
||||
workspaceId: workspace.workspaceId,
|
||||
action: "restore",
|
||||
});
|
||||
expect(recreated).toEqual(["wks_15a1b5630ebaab33", "wks_15a1b5630ebaab33"]);
|
||||
expect(unarchived).toEqual(["wks_15a1b5630ebaab33"]);
|
||||
expect(existsSync(worktreeRoot)).toBe(true);
|
||||
expect(existsSync(workspaceCwd)).toBe(true);
|
||||
expect(unarchived).toEqual([workspace.workspaceId]);
|
||||
});
|
||||
|
||||
test("keeps an exact-subdirectory workspace archived when its branch lacks that directory", async () => {
|
||||
const { tempDir, repoDir } = createGitRepository();
|
||||
const branch = "feature/without-subproject";
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
const paseoHome = join(tempDir, "paseo-home");
|
||||
const worktreesRoot = join(tempDir, "worktrees");
|
||||
const created = await createWorktree({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "without-subproject",
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
});
|
||||
const worktreeRoot = realpathSync(created.worktreePath);
|
||||
const workspaceCwd = join(worktreeRoot, "packages", "app");
|
||||
rmSync(worktreeRoot, { recursive: true, force: true });
|
||||
execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const project = createProject({ rootPath: repoDir });
|
||||
const workspace = createWorkspace({
|
||||
workspaceId: "ws-missing-restored-subdirectory",
|
||||
cwd: workspaceCwd,
|
||||
branch,
|
||||
worktreeRoot,
|
||||
mainRepoRoot: repoDir,
|
||||
});
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async (workspaceId) =>
|
||||
workspaceId === workspace.workspaceId ? workspace : null,
|
||||
getProject: async (projectId) => (projectId === project.projectId ? project : null),
|
||||
isDirectory: async (targetPath) =>
|
||||
existsSync(targetPath) && statSync(targetPath).isDirectory(),
|
||||
unarchiveWorkspace: async (record) => {
|
||||
unarchived.push(record.workspaceId);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.restore(workspace.workspaceId)).rejects.toThrow(
|
||||
"Selected project directory is missing from the restored worktree",
|
||||
);
|
||||
expect(unarchived).toEqual([]);
|
||||
expect(existsSync(worktreeRoot)).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.includes("without-subproject"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps the workspace archived when its persisted source repository is missing", async () => {
|
||||
const workspace = createWorkspace({ mainRepoRoot: "/missing-source" });
|
||||
const { service, unarchived } = createHarness({
|
||||
workspace,
|
||||
directories: ["/project"],
|
||||
});
|
||||
|
||||
await expect(service.inspect(workspace.workspaceId)).resolves.toEqual({
|
||||
kind: "unavailable",
|
||||
workspaceId: workspace.workspaceId,
|
||||
reason: "project_directory_missing",
|
||||
message: "The source repository needed to restore this worktree no longer exists.",
|
||||
});
|
||||
await expect(service.restore(workspace.workspaceId)).rejects.toThrow(
|
||||
"The source repository needed to restore this worktree no longer exists.",
|
||||
);
|
||||
expect(unarchived).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function createGitRepository(): { tempDir: string; repoDir: string } {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "paseo-workspace-recovery-"));
|
||||
tempDirectories.push(tempDir);
|
||||
const repoDir = join(tempDir, "repo");
|
||||
mkdirSync(repoDir);
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Paseo Test"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
writeFileSync(join(repoDir, "README.md"), "initial\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" });
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
+157
-28
@@ -1,3 +1,14 @@
|
||||
import { basename } from "node:path";
|
||||
|
||||
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
import { runGitCommand } from "../../../utils/run-git-command.js";
|
||||
import {
|
||||
createWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
mapWorkspaceCwdToWorktree,
|
||||
rollbackCreatedPaseoWorktree,
|
||||
} from "../../../utils/worktree.js";
|
||||
import { WorktreeRequestError, toWorktreeRequestError } from "../../worktree-errors.js";
|
||||
import {
|
||||
resolveWorkspaceDisplayName,
|
||||
type PersistedProjectRecord,
|
||||
@@ -32,14 +43,32 @@ export interface WorkspaceRecoveryService {
|
||||
restore(workspaceId: string): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }>;
|
||||
}
|
||||
|
||||
type RecoveryPlan =
|
||||
| {
|
||||
kind: "unarchive";
|
||||
state: Extract<WorkspaceRecoveryState, { kind: "recoverable" }>;
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
}
|
||||
| {
|
||||
kind: "restore";
|
||||
state: Extract<WorkspaceRecoveryState, { kind: "recoverable" }>;
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
sourceRepoRoot: string;
|
||||
};
|
||||
|
||||
type UnavailableRecoveryState = Extract<WorkspaceRecoveryState, { kind: "unavailable" }>;
|
||||
|
||||
export function createWorkspaceRecoveryService(deps: {
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||
getProject: (projectId: string) => Promise<PersistedProjectRecord | null>;
|
||||
isDirectory: (path: string) => Promise<boolean>;
|
||||
recreateWorktree: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
unarchiveWorkspace: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
}): WorkspaceRecoveryService {
|
||||
async function inspect(workspaceId: string): Promise<WorkspaceRecoveryState> {
|
||||
async function resolveRecovery(
|
||||
workspaceId: string,
|
||||
): Promise<UnavailableRecoveryState | RecoveryPlan> {
|
||||
const workspace = await deps.getWorkspace(workspaceId);
|
||||
if (!workspace) {
|
||||
return {
|
||||
@@ -69,13 +98,7 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
}
|
||||
|
||||
if (await deps.isDirectory(workspace.cwd)) {
|
||||
return {
|
||||
kind: "recoverable",
|
||||
workspaceId,
|
||||
workspaceName: resolveWorkspaceDisplayName(workspace),
|
||||
action: "unarchive",
|
||||
branch: workspace.branch,
|
||||
};
|
||||
return createRecoveryPlan({ action: "unarchive", workspace });
|
||||
}
|
||||
|
||||
if (workspace.kind !== "worktree") {
|
||||
@@ -94,42 +117,148 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
message: "The archived worktree has no branch recorded, so it cannot be restored.",
|
||||
};
|
||||
}
|
||||
if (!(await deps.isDirectory(project.rootPath))) {
|
||||
|
||||
// COMPAT(worktreeRestoreMissingMainRepoRoot): records created before v0.1.110
|
||||
// lack placement ownership; remove the project-root fallback after 2027-01-17.
|
||||
const sourceRepoRoot = workspace.mainRepoRoot ?? project.rootPath;
|
||||
if (!(await deps.isDirectory(sourceRepoRoot))) {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
workspaceId,
|
||||
reason: "project_directory_missing",
|
||||
message: "The project directory needed to restore this worktree no longer exists.",
|
||||
message: "The source repository needed to restore this worktree no longer exists.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "recoverable",
|
||||
workspaceId,
|
||||
workspaceName: resolveWorkspaceDisplayName(workspace),
|
||||
action: "restore",
|
||||
branch: workspace.branch,
|
||||
};
|
||||
return createRecoveryPlan({ action: "restore", workspace, sourceRepoRoot });
|
||||
}
|
||||
|
||||
async function inspect(workspaceId: string): Promise<WorkspaceRecoveryState> {
|
||||
const resolved = await resolveRecovery(workspaceId);
|
||||
return resolved.kind === "unavailable" ? resolved : resolved.state;
|
||||
}
|
||||
|
||||
async function restore(
|
||||
workspaceId: string,
|
||||
): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }> {
|
||||
const state = await inspect(workspaceId);
|
||||
if (state.kind === "unavailable") {
|
||||
throw new Error(state.message);
|
||||
const resolved = await resolveRecovery(workspaceId);
|
||||
if (resolved.kind === "unavailable") {
|
||||
throw new Error(resolved.message);
|
||||
}
|
||||
|
||||
const workspace = await deps.getWorkspace(workspaceId);
|
||||
if (!workspace?.archivedAt) {
|
||||
throw new Error("The archived workspace changed before it could be recovered.");
|
||||
if (resolved.kind === "restore") {
|
||||
await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot);
|
||||
}
|
||||
if (state.action === "restore") {
|
||||
await deps.recreateWorktree(workspace);
|
||||
await deps.unarchiveWorkspace(resolved.workspace);
|
||||
return { workspaceId, action: resolved.kind };
|
||||
}
|
||||
|
||||
async function recreateArchivedWorktree(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
sourceRepoRoot: string,
|
||||
): Promise<void> {
|
||||
const branch = workspace.branch;
|
||||
if (!branch) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Workspace ${workspace.workspaceId} has no branch to restore`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await runGitCommand(["worktree", "prune"], { cwd: sourceRepoRoot, timeout: 30_000 });
|
||||
} catch {
|
||||
// A stale worktree registration is not guaranteed; creation reports any real conflict.
|
||||
}
|
||||
|
||||
let previousWorktreePath = workspace.worktreeRoot;
|
||||
if (!previousWorktreePath) {
|
||||
// COMPAT(worktreeRestoreMissingWorktreeRoot): records created before v0.1.110
|
||||
// lack durable backing placement; remove filesystem discovery after 2027-01-17.
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(workspace.cwd, {
|
||||
paseoHome: deps.paseoHome,
|
||||
worktreesRoot: deps.worktreesRoot,
|
||||
});
|
||||
previousWorktreePath = ownership.allowed
|
||||
? (ownership.worktreePath ?? workspace.cwd)
|
||||
: workspace.cwd;
|
||||
}
|
||||
|
||||
let recreatedWorktreePath: string;
|
||||
try {
|
||||
const result = await createWorktree({
|
||||
cwd: sourceRepoRoot,
|
||||
worktreeSlug: basename(previousWorktreePath),
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
paseoHome: deps.paseoHome,
|
||||
worktreesRoot: deps.worktreesRoot,
|
||||
});
|
||||
recreatedWorktreePath = result.worktreePath;
|
||||
} catch (error) {
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
|
||||
try {
|
||||
const recreatedWorkspacePath = mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath: previousWorktreePath,
|
||||
workspaceCwd: workspace.cwd,
|
||||
targetWorktreePath: recreatedWorktreePath,
|
||||
});
|
||||
if (!createRealpathAwarePathMatcher(workspace.cwd)(recreatedWorkspacePath)) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Recreated worktree diverged from ${workspace.cwd}: ${recreatedWorkspacePath}`,
|
||||
});
|
||||
}
|
||||
if (!(await deps.isDirectory(recreatedWorkspacePath))) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Selected project directory is missing from the restored worktree: ${recreatedWorkspacePath}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return rollbackCreatedPaseoWorktree(
|
||||
{
|
||||
cwd: sourceRepoRoot,
|
||||
worktreePath: recreatedWorktreePath,
|
||||
teardownCwds: [],
|
||||
paseoHome: deps.paseoHome,
|
||||
worktreesBaseRoot: deps.worktreesRoot,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
await deps.unarchiveWorkspace(workspace);
|
||||
return { workspaceId, action: state.action };
|
||||
}
|
||||
|
||||
return { inspect, restore };
|
||||
}
|
||||
|
||||
function createRecoveryPlan(
|
||||
input:
|
||||
| { action: "unarchive"; workspace: PersistedWorkspaceRecord }
|
||||
| { action: "restore"; workspace: PersistedWorkspaceRecord; sourceRepoRoot: string },
|
||||
): RecoveryPlan {
|
||||
const state = {
|
||||
kind: "recoverable" as const,
|
||||
workspaceId: input.workspace.workspaceId,
|
||||
workspaceName: resolveWorkspaceDisplayName(input.workspace),
|
||||
branch: input.workspace.branch,
|
||||
};
|
||||
if (input.action === "restore") {
|
||||
return {
|
||||
kind: input.action,
|
||||
state: { ...state, action: input.action },
|
||||
workspace: input.workspace,
|
||||
sourceRepoRoot: input.sourceRepoRoot,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: input.action,
|
||||
state: {
|
||||
...state,
|
||||
action: input.action,
|
||||
},
|
||||
workspace: input.workspace,
|
||||
};
|
||||
}
|
||||
|
||||
+187
-30
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pino } from "pino";
|
||||
@@ -6,34 +6,29 @@ import { afterEach, describe, expect, test } from "vitest";
|
||||
import type { SessionOutboundMessage, StartWorkspaceScriptRequest } from "../../messages.js";
|
||||
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "../../service-proxy.js";
|
||||
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
||||
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../../workspace-registry.js";
|
||||
import type { WorkspaceGitMetadata } from "../../workspace-git-metadata.js";
|
||||
import type {
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "../../workspace-registry.js";
|
||||
import { createNoGitWorkspaceRuntimeSnapshot } from "../../test-utils/workspace-git-service-stub.js";
|
||||
import { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
|
||||
import type {
|
||||
SpawnWorkspaceScriptOptions,
|
||||
WorktreeScriptResult,
|
||||
} from "../../worktree-bootstrap.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import { createWorkspaceScriptsService } from "./workspace-scripts-service.js";
|
||||
import { deriveProjectServiceSlug } from "../../workspace-git-metadata.js";
|
||||
|
||||
// The production module reads only WorkspaceGitService.{peekSnapshot,getWorkspaceGitMetadata},
|
||||
// The production module reads only WorkspaceGitService.{peekSnapshot,getProjectSlug},
|
||||
// WorkspaceRegistry.get, and forwards the launcher + opaque managers to the injected
|
||||
// spawnWorkspaceScript port. The fakes below implement exactly that slice; the service proxy and
|
||||
// runtime store are the real in-memory implementations, and spawning is injected so no process runs.
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
const gitMetadata: WorkspaceGitMetadata = {
|
||||
projectKind: "git",
|
||||
projectDisplayName: "repo",
|
||||
workspaceDisplayName: "repo",
|
||||
gitRemote: null,
|
||||
isWorktree: false,
|
||||
projectSlug: "paseo",
|
||||
repoRoot: "/tmp/repo",
|
||||
currentBranch: "feature/scripts",
|
||||
remoteUrl: null,
|
||||
};
|
||||
|
||||
function fakeWorkspaceRegistry(
|
||||
record: PersistedWorkspaceRecord | null,
|
||||
): Pick<WorkspaceRegistry, "get"> {
|
||||
@@ -44,13 +39,28 @@ function fakeWorkspaceRegistry(
|
||||
};
|
||||
}
|
||||
|
||||
function fakeGitService(metadata: WorkspaceGitMetadata = gitMetadata) {
|
||||
function fakeProjectRegistry(record: PersistedProjectRecord | null): Pick<ProjectRegistry, "get"> {
|
||||
return {
|
||||
async get() {
|
||||
return record;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeGitService() {
|
||||
const snapshot = createNoGitWorkspaceRuntimeSnapshot("/tmp/repo");
|
||||
snapshot.git = {
|
||||
...snapshot.git,
|
||||
isGit: true,
|
||||
repoRoot: "/tmp/repo",
|
||||
currentBranch: "feature/scripts",
|
||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||
hasRemote: true,
|
||||
};
|
||||
|
||||
return {
|
||||
peekSnapshot() {
|
||||
return null;
|
||||
},
|
||||
async getWorkspaceGitMetadata() {
|
||||
return metadata;
|
||||
return snapshot;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -64,7 +74,9 @@ interface BuildOptions {
|
||||
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null;
|
||||
terminalManager?: TerminalManager | null;
|
||||
workspace?: PersistedWorkspaceRecord | null;
|
||||
project?: PersistedProjectRecord | null;
|
||||
spawnThrows?: string;
|
||||
gitService?: Pick<WorkspaceGitService, "peekSnapshot">;
|
||||
}
|
||||
|
||||
function buildService(options: BuildOptions = {}) {
|
||||
@@ -87,7 +99,8 @@ function buildService(options: BuildOptions = {}) {
|
||||
terminalManager:
|
||||
options.terminalManager === undefined ? availableTerminalManager : options.terminalManager,
|
||||
workspaceRegistry: fakeWorkspaceRegistry(workspace),
|
||||
workspaceGitService: fakeGitService(),
|
||||
projectRegistry: fakeProjectRegistry(options.project ?? null),
|
||||
workspaceGitService: options.gitService ?? fakeGitService(),
|
||||
getDaemonTcpPort: () => 6767,
|
||||
getDaemonTcpHost: () => "127.0.0.1",
|
||||
serviceProxyPublicBaseUrl: null,
|
||||
@@ -130,28 +143,77 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("buildSnapshot", () => {
|
||||
test("returns no scripts when the service proxy is unavailable", () => {
|
||||
test("returns no scripts when the service proxy is unavailable", async () => {
|
||||
const { service } = buildService({ serviceProxy: null });
|
||||
expect(service.buildSnapshot("ws-1", "/tmp/repo")).toEqual([]);
|
||||
expect(
|
||||
service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns no scripts when the runtime store is unavailable", () => {
|
||||
test("returns no scripts when the runtime store is unavailable", async () => {
|
||||
const { service } = buildService({ scriptRuntimeStore: null });
|
||||
expect(service.buildSnapshot("ws-1", "/tmp/repo")).toEqual([]);
|
||||
expect(
|
||||
service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns no scripts for a workspace without a paseo.json", () => {
|
||||
test("returns no scripts for a workspace without a paseo.json", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
|
||||
tempDirs.push(dir);
|
||||
const { service } = buildService();
|
||||
expect(service.buildSnapshot("ws-1", dir)).toEqual([]);
|
||||
expect(
|
||||
service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("projects service hostnames without a Git snapshot", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
|
||||
tempDirs.push(directory);
|
||||
writeFileSync(
|
||||
join(directory, "paseo.json"),
|
||||
JSON.stringify({ scripts: { app: { type: "service", command: "npm run app", port: 3000 } } }),
|
||||
);
|
||||
const project = {
|
||||
projectId: "prj_no_snapshot",
|
||||
rootPath: directory,
|
||||
kind: "git",
|
||||
displayName: "app",
|
||||
customName: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
} as PersistedProjectRecord;
|
||||
const workspace = {
|
||||
workspaceId: "ws-no-snapshot",
|
||||
projectId: project.projectId,
|
||||
cwd: directory,
|
||||
branch: "feature/persisted",
|
||||
} as PersistedWorkspaceRecord;
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
const { service, spawnCalls } = buildService({
|
||||
workspace,
|
||||
project,
|
||||
serviceProxy,
|
||||
gitService: { peekSnapshot: () => undefined },
|
||||
});
|
||||
|
||||
expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe(
|
||||
serviceProxy.projectWorkspaceService({
|
||||
projectSlug: deriveProjectServiceSlug(project),
|
||||
branchName: workspace.branch,
|
||||
scriptName: "app",
|
||||
daemonPort: 6767,
|
||||
}).hostname,
|
||||
);
|
||||
await service.start({ ...request, workspaceId: workspace.workspaceId });
|
||||
expect(spawnCalls[0]?.branchName).toBe(workspace.branch);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emitStatusUpdate", () => {
|
||||
test("emits one script_status_update carrying the snapshot", () => {
|
||||
test("emits one script_status_update carrying the snapshot", async () => {
|
||||
const { service, emitted } = buildService();
|
||||
service.emitStatusUpdate("ws-1", "/tmp/repo");
|
||||
await service.emitStatusUpdate("ws-1", "/tmp/repo");
|
||||
expect(emitted).toEqual([
|
||||
{ type: "script_status_update", payload: { workspaceId: "ws-1", scripts: [] } },
|
||||
]);
|
||||
@@ -225,6 +287,101 @@ describe("start", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the exact project root for a service hostname", async () => {
|
||||
const workspace = {
|
||||
workspaceId: "ws-app",
|
||||
projectId: "prj-app",
|
||||
cwd: "/repo/apps/app",
|
||||
} as PersistedWorkspaceRecord;
|
||||
const project = {
|
||||
projectId: "prj-app",
|
||||
rootPath: "/repo/apps/app",
|
||||
kind: "git",
|
||||
displayName: "app",
|
||||
customName: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
} as PersistedProjectRecord;
|
||||
const { service, spawnCalls } = buildService({ workspace, project });
|
||||
|
||||
await service.start({ ...request, workspaceId: workspace.workspaceId });
|
||||
|
||||
expect(spawnCalls[0]).toMatchObject({
|
||||
projectSlug: deriveProjectServiceSlug(project),
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps same-named service projects distinct", async () => {
|
||||
const projectA = {
|
||||
projectId: "prj-app-a",
|
||||
rootPath: "/repo-a/app",
|
||||
kind: "git",
|
||||
displayName: "app",
|
||||
customName: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
} as PersistedProjectRecord;
|
||||
const projectB = { ...projectA, projectId: "prj-app-b", rootPath: "/repo-b/app" };
|
||||
const workspaceA = {
|
||||
workspaceId: "ws-app-a",
|
||||
projectId: projectA.projectId,
|
||||
cwd: projectA.rootPath,
|
||||
} as PersistedWorkspaceRecord;
|
||||
const workspaceB = {
|
||||
workspaceId: "ws-app-b",
|
||||
projectId: projectB.projectId,
|
||||
cwd: projectB.rootPath,
|
||||
} as PersistedWorkspaceRecord;
|
||||
const first = buildService({ workspace: workspaceA, project: projectA });
|
||||
const second = buildService({ workspace: workspaceB, project: projectB });
|
||||
|
||||
await first.service.start({ ...request, workspaceId: workspaceA.workspaceId });
|
||||
await second.service.start({ ...request, workspaceId: workspaceB.workspaceId });
|
||||
|
||||
expect(first.spawnCalls[0]?.projectSlug).not.toBe(second.spawnCalls[0]?.projectSlug);
|
||||
});
|
||||
|
||||
test("predicts the same service hostname that start registers", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
|
||||
tempDirs.push(directory);
|
||||
writeFileSync(
|
||||
join(directory, "paseo.json"),
|
||||
JSON.stringify({ scripts: { app: { type: "service", command: "npm run app", port: 3000 } } }),
|
||||
);
|
||||
const project = {
|
||||
projectId: "prj_hostname",
|
||||
rootPath: directory,
|
||||
kind: "git",
|
||||
displayName: "app",
|
||||
customName: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
} as PersistedProjectRecord;
|
||||
const workspace = {
|
||||
workspaceId: "ws-hostname",
|
||||
projectId: project.projectId,
|
||||
cwd: directory,
|
||||
} as PersistedWorkspaceRecord;
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
const { service, spawnCalls } = buildService({ workspace, project, serviceProxy });
|
||||
|
||||
const snapshot = service.buildSnapshot(workspace, project);
|
||||
await service.start({ ...request, workspaceId: workspace.workspaceId });
|
||||
|
||||
const started = spawnCalls[0]!;
|
||||
expect(snapshot[0]?.hostname).toBe(
|
||||
serviceProxy.projectWorkspaceService({
|
||||
projectSlug: started.projectSlug,
|
||||
branchName: started.branchName,
|
||||
scriptName: started.scriptName,
|
||||
daemonPort: started.daemonPort,
|
||||
}).hostname,
|
||||
);
|
||||
});
|
||||
|
||||
test("reports the launcher error when spawning fails", async () => {
|
||||
const { service, emitted } = buildService({ spawnThrows: "boom" });
|
||||
await service.start(request);
|
||||
|
||||
@@ -9,7 +9,12 @@ import type { ServiceProxySubsystem } from "../../service-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
|
||||
import type { ScriptHealthState } from "../../script-health-monitor.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import type { WorkspaceRegistry } from "../../workspace-registry.js";
|
||||
import type {
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "../../workspace-registry.js";
|
||||
import type {
|
||||
SpawnWorkspaceScriptOptions,
|
||||
WorktreeScriptResult,
|
||||
@@ -18,15 +23,10 @@ import {
|
||||
buildWorkspaceScriptPayloads,
|
||||
readPaseoConfigForProjection,
|
||||
} from "../../script-status-projection.js";
|
||||
import { deriveProjectSlug } from "../../workspace-git-metadata.js";
|
||||
import { deriveProjectServiceSlug, deriveProjectSlug } from "../../workspace-git-metadata.js";
|
||||
|
||||
type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"];
|
||||
|
||||
interface WorkspaceScriptGitMetadata {
|
||||
projectSlug: string;
|
||||
currentBranch: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The service-proxy-backed scripts a workspace exposes: build the scripts payload
|
||||
* snapshot, emit a script_status_update to clients, and start a script.
|
||||
@@ -37,21 +37,22 @@ interface WorkspaceScriptGitMetadata {
|
||||
* that assembly and guard across the session.
|
||||
*/
|
||||
export interface WorkspaceScriptsService {
|
||||
buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload;
|
||||
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void;
|
||||
buildSnapshot(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
project?: PersistedProjectRecord | null,
|
||||
): WorkspaceScriptsPayload;
|
||||
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise<void>;
|
||||
start(request: StartWorkspaceScriptRequest): Promise<void>;
|
||||
}
|
||||
|
||||
type WorkspaceScriptsGitSource = Pick<
|
||||
WorkspaceGitService,
|
||||
"peekSnapshot" | "getWorkspaceGitMetadata"
|
||||
>;
|
||||
type WorkspaceScriptsGitSource = Pick<WorkspaceGitService, "peekSnapshot">;
|
||||
|
||||
export function createWorkspaceScriptsService(deps: {
|
||||
serviceProxy: ServiceProxySubsystem | null;
|
||||
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||
terminalManager: TerminalManager | null;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get">;
|
||||
projectRegistry: Pick<ProjectRegistry, "get">;
|
||||
workspaceGitService: WorkspaceScriptsGitSource;
|
||||
getDaemonTcpPort: (() => number | null) | null;
|
||||
getDaemonTcpHost: (() => string | null) | null;
|
||||
@@ -66,6 +67,7 @@ export function createWorkspaceScriptsService(deps: {
|
||||
scriptRuntimeStore,
|
||||
terminalManager,
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService,
|
||||
getDaemonTcpPort,
|
||||
getDaemonTcpHost,
|
||||
@@ -76,45 +78,60 @@ export function createWorkspaceScriptsService(deps: {
|
||||
spawnWorkspaceScript,
|
||||
} = deps;
|
||||
|
||||
function resolveGitMetadata(workspaceDirectory: string): WorkspaceScriptGitMetadata | undefined {
|
||||
const snapshot = workspaceGitService.peekSnapshot(workspaceDirectory);
|
||||
if (!snapshot) {
|
||||
return undefined;
|
||||
function resolveGitMetadata(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
project: { projectId: string; rootPath: string } | null,
|
||||
) {
|
||||
const snapshot = workspaceGitService.peekSnapshot(workspace.cwd);
|
||||
const currentBranch = snapshot?.git.currentBranch ?? workspace.branch ?? null;
|
||||
if (project) {
|
||||
return {
|
||||
projectSlug: deriveProjectServiceSlug(project),
|
||||
currentBranch,
|
||||
};
|
||||
}
|
||||
if (!snapshot) return undefined;
|
||||
return {
|
||||
projectSlug: deriveProjectSlug(
|
||||
workspaceDirectory,
|
||||
workspace.cwd,
|
||||
snapshot.git.isGit ? snapshot.git.remoteUrl : null,
|
||||
),
|
||||
currentBranch: snapshot.git.currentBranch,
|
||||
currentBranch,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload {
|
||||
function buildSnapshot(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
project: PersistedProjectRecord | null = null,
|
||||
): WorkspaceScriptsPayload {
|
||||
if (!serviceProxy || !scriptRuntimeStore) {
|
||||
return [];
|
||||
}
|
||||
return buildWorkspaceScriptPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.cwd,
|
||||
paseoConfig: readPaseoConfigForProjection(workspace.cwd, logger),
|
||||
serviceProxy,
|
||||
runtimeStore: scriptRuntimeStore,
|
||||
daemonPort: getDaemonTcpPort?.() ?? null,
|
||||
serviceProxyPublicBaseUrl,
|
||||
gitMetadata: resolveGitMetadata(workspaceDirectory),
|
||||
gitMetadata: resolveGitMetadata(workspace, project),
|
||||
resolveHealth: resolveScriptHealth ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void {
|
||||
emit({
|
||||
type: "script_status_update",
|
||||
payload: {
|
||||
workspaceId,
|
||||
scripts: buildSnapshot(workspaceId, workspaceDirectory),
|
||||
},
|
||||
});
|
||||
async function emitStatusUpdate(workspaceId: string, _workspaceDirectory: string): Promise<void> {
|
||||
try {
|
||||
const workspace = await workspaceRegistry.get(workspaceId);
|
||||
if (!workspace) return;
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
emit({
|
||||
type: "script_status_update",
|
||||
payload: { workspaceId, scripts: buildSnapshot(workspace, project) },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, workspaceId }, "Failed to project workspace script status");
|
||||
}
|
||||
}
|
||||
|
||||
async function start(request: StartWorkspaceScriptRequest): Promise<void> {
|
||||
@@ -127,13 +144,23 @@ export function createWorkspaceScriptsService(deps: {
|
||||
if (!workspace) {
|
||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
||||
}
|
||||
const gitMetadata = await workspaceGitService.getWorkspaceGitMetadata(workspace.cwd);
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
const projectSlug = project
|
||||
? deriveProjectServiceSlug(project)
|
||||
: deriveProjectSlug(
|
||||
workspace.cwd,
|
||||
workspaceGitService.peekSnapshot(workspace.cwd)?.git.remoteUrl ?? null,
|
||||
);
|
||||
const branchName =
|
||||
workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ??
|
||||
workspace.branch ??
|
||||
null;
|
||||
|
||||
const serviceResult = await spawnWorkspaceScript({
|
||||
repoRoot: workspace.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
projectSlug: gitMetadata.projectSlug,
|
||||
branchName: gitMetadata.currentBranch,
|
||||
projectSlug,
|
||||
branchName,
|
||||
scriptName: request.scriptName,
|
||||
daemonPort: getDaemonTcpPort?.() ?? null,
|
||||
daemonListenHost: getDaemonTcpHost?.() ?? null,
|
||||
@@ -143,11 +170,11 @@ export function createWorkspaceScriptsService(deps: {
|
||||
terminalManager,
|
||||
logger,
|
||||
onLifecycleChanged: () => {
|
||||
emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
||||
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
||||
},
|
||||
});
|
||||
|
||||
emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
||||
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
||||
emit({
|
||||
type: "start_workspace_script_response",
|
||||
payload: {
|
||||
|
||||
@@ -99,6 +99,7 @@ describe("snapshot mutation ownership boundary", () => {
|
||||
const session = asInternals<SessionInternals>(
|
||||
new Session({
|
||||
clientId: "test-client",
|
||||
scopes: ["*"],
|
||||
onMessage,
|
||||
logger: createStub<SessionOptions["logger"]>(logger),
|
||||
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
|
||||
|
||||
@@ -57,10 +57,12 @@ interface FakeAgentSessionOptions {
|
||||
sessionId?: string;
|
||||
memoryMarker?: string | null;
|
||||
closeSession?: () => Promise<void>;
|
||||
onStartTurn?: (prompt: AgentPromptInput) => void;
|
||||
}
|
||||
|
||||
export interface TestAgentClientOptions {
|
||||
closeSession?: () => Promise<void>;
|
||||
onStartTurn?: (prompt: AgentPromptInput) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
@@ -329,6 +331,7 @@ class FakeAgentSession implements AgentSession {
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
|
||||
private readonly closeSession: (() => Promise<void>) | undefined;
|
||||
private readonly onStartTurn: ((prompt: AgentPromptInput) => void) | undefined;
|
||||
|
||||
constructor(options: FakeAgentSessionOptions) {
|
||||
this.providerName = options.providerName;
|
||||
@@ -336,6 +339,7 @@ class FakeAgentSession implements AgentSession {
|
||||
this.id = options.sessionId ?? randomUUID();
|
||||
this.memoryMarker = options.memoryMarker ?? null;
|
||||
this.closeSession = options.closeSession;
|
||||
this.onStartTurn = options.onStartTurn;
|
||||
this.historyPath = path.join(
|
||||
tmpdir(),
|
||||
"paseo-fake-provider-history",
|
||||
@@ -426,6 +430,7 @@ class FakeAgentSession implements AgentSession {
|
||||
|
||||
const turnId = `fake-turn-${this.nextTurnOrdinal++}`;
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.onStartTurn?.(prompt);
|
||||
|
||||
void this.emitTurnEvents(prompt);
|
||||
|
||||
@@ -1180,6 +1185,7 @@ class FakeAgentClient implements AgentClient {
|
||||
providerName: this.provider,
|
||||
config: { ...config },
|
||||
closeSession: this.options.closeSession,
|
||||
onStartTurn: this.options.onStartTurn,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1203,6 +1209,7 @@ class FakeAgentClient implements AgentClient {
|
||||
sessionId: handle.sessionId,
|
||||
memoryMarker: typeof marker === "string" ? marker : null,
|
||||
closeSession: this.options.closeSession,
|
||||
onStartTurn: this.options.onStartTurn,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createHubCommand } from "../../../../cli/src/commands/hub/index.js";
|
||||
|
||||
const argv = [process.argv[0] ?? "node", process.argv[1] ?? "paseo", ...process.argv.slice(3)];
|
||||
await createHubCommand().parseAsync(argv, { from: "node" });
|
||||
@@ -1,9 +1,5 @@
|
||||
import { basename } from "node:path";
|
||||
import type { CheckoutDiffResult } from "../../utils/checkout-git.js";
|
||||
import {
|
||||
buildWorkspaceGitMetadataFromSnapshot,
|
||||
type WorkspaceGitMetadata,
|
||||
} from "../workspace-git-metadata.js";
|
||||
import { deriveProjectSlug } from "../workspace-git-metadata.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
|
||||
|
||||
export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||
@@ -39,6 +35,9 @@ export function createNoopWorkspaceGitService(
|
||||
registerWorkspace: () => ({
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
onSnapshotUpdated: () => ({
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async (cwd: string) => ({
|
||||
cwd,
|
||||
@@ -56,17 +55,9 @@ export function createNoopWorkspaceGitService(
|
||||
suggestBranchesForCwd: async () => [],
|
||||
listStashes: async () => [],
|
||||
listWorktrees: async () => [],
|
||||
getWorkspaceGitMetadata: async (cwd: string, options): Promise<WorkspaceGitMetadata> => {
|
||||
getProjectSlug: async (cwd: string) => {
|
||||
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
|
||||
return buildWorkspaceGitMetadataFromSnapshot({
|
||||
cwd,
|
||||
directoryName: options?.directoryName ?? basename(cwd),
|
||||
isGit: snapshot.git.isGit,
|
||||
repoRoot: snapshot.git.repoRoot,
|
||||
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||
currentBranch: snapshot.git.currentBranch,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
});
|
||||
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
|
||||
},
|
||||
resolveForge: async () => null,
|
||||
resolveRepoRoot: async (cwd: string) => cwd,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user