First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildConfigMutationFailureMessage,
|
||||
buildMutationSideEffectCommands,
|
||||
buildQueuedMutationBlock,
|
||||
resolveConfigMutationPostRunEffects,
|
||||
resolveConfigMutationStatusLine,
|
||||
resolveMutationStartGuard,
|
||||
runConfigMutationWorkflow,
|
||||
} from "@/features/agents/operations/mutationLifecycleWorkflow";
|
||||
import { shouldStartNextConfigMutation } from "@/features/agents/operations/configMutationGatePolicy";
|
||||
|
||||
describe("mutationLifecycleWorkflow integration", () => {
|
||||
it("page create handler uses shared start guard and queued block shape", () => {
|
||||
const denied = resolveMutationStartGuard({
|
||||
status: "disconnected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
});
|
||||
expect(denied).toEqual({ kind: "deny", reason: "not-connected" });
|
||||
|
||||
const allowed = resolveMutationStartGuard({
|
||||
status: "connected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
});
|
||||
expect(allowed).toEqual({ kind: "allow" });
|
||||
|
||||
const queued = buildQueuedMutationBlock({
|
||||
kind: "create-agent",
|
||||
agentId: "",
|
||||
agentName: "Agent One",
|
||||
startedAt: 42,
|
||||
});
|
||||
expect(queued).toEqual({
|
||||
kind: "create-agent",
|
||||
agentId: "",
|
||||
agentName: "Agent One",
|
||||
phase: "queued",
|
||||
startedAt: 42,
|
||||
sawDisconnect: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("delete workflow maps awaiting-restart outcome to awaiting-restart block phase", async () => {
|
||||
const result = await runConfigMutationWorkflow(
|
||||
{ kind: "delete-agent", isLocalGateway: false },
|
||||
{
|
||||
executeMutation: async () => undefined,
|
||||
shouldAwaitRemoteRestart: async () => true,
|
||||
}
|
||||
);
|
||||
|
||||
const effects = resolveConfigMutationPostRunEffects(result);
|
||||
expect(effects).toEqual({
|
||||
shouldReloadAgents: false,
|
||||
shouldClearBlock: false,
|
||||
awaitingRestartPatch: {
|
||||
phase: "awaiting-restart",
|
||||
sawDisconnect: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rename workflow maps completed outcome to load-and-clear flow", async () => {
|
||||
const result = await runConfigMutationWorkflow(
|
||||
{ kind: "rename-agent", isLocalGateway: false },
|
||||
{
|
||||
executeMutation: async () => undefined,
|
||||
shouldAwaitRemoteRestart: async () => false,
|
||||
}
|
||||
);
|
||||
|
||||
const effects = resolveConfigMutationPostRunEffects(result);
|
||||
let didLoadAgents = false;
|
||||
let block: { phase: string; sawDisconnect: boolean } | null = {
|
||||
phase: "mutating",
|
||||
sawDisconnect: false,
|
||||
};
|
||||
if (effects.shouldReloadAgents) {
|
||||
didLoadAgents = true;
|
||||
}
|
||||
if (effects.shouldClearBlock) {
|
||||
block = null;
|
||||
}
|
||||
|
||||
expect(didLoadAgents).toBe(true);
|
||||
expect(block).toBeNull();
|
||||
expect(effects.awaitingRestartPatch).toBeNull();
|
||||
});
|
||||
|
||||
it("page rename and delete handlers share lifecycle guard plus post-run transitions", async () => {
|
||||
const blocked = resolveMutationStartGuard({
|
||||
status: "connected",
|
||||
hasCreateBlock: true,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
});
|
||||
expect(blocked).toEqual({
|
||||
kind: "deny",
|
||||
reason: "create-block-active",
|
||||
});
|
||||
|
||||
const allowed = resolveMutationStartGuard({
|
||||
status: "connected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
});
|
||||
expect(allowed).toEqual({ kind: "allow" });
|
||||
|
||||
const executeMutation = vi.fn(async () => undefined);
|
||||
|
||||
const renameCompleted = await runConfigMutationWorkflow(
|
||||
{
|
||||
kind: "rename-agent",
|
||||
isLocalGateway: false,
|
||||
},
|
||||
{
|
||||
executeMutation,
|
||||
shouldAwaitRemoteRestart: async () => false,
|
||||
}
|
||||
);
|
||||
expect(buildMutationSideEffectCommands({ disposition: renameCompleted.disposition })).toEqual([
|
||||
{ kind: "reload-agents" },
|
||||
{ kind: "clear-mutation-block" },
|
||||
{ kind: "set-mobile-pane", pane: "chat" },
|
||||
]);
|
||||
|
||||
const deleteAwaitingRestart = await runConfigMutationWorkflow(
|
||||
{
|
||||
kind: "delete-agent",
|
||||
isLocalGateway: false,
|
||||
},
|
||||
{
|
||||
executeMutation,
|
||||
shouldAwaitRemoteRestart: async () => true,
|
||||
}
|
||||
);
|
||||
expect(buildMutationSideEffectCommands({ disposition: deleteAwaitingRestart.disposition })).toEqual(
|
||||
[{ kind: "patch-mutation-block", patch: { phase: "awaiting-restart", sawDisconnect: false } }]
|
||||
);
|
||||
expect(executeMutation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses typed mutation commands for lifecycle side effects instead of inline branching", async () => {
|
||||
const commandLog: string[] = [];
|
||||
const runCommands = async (
|
||||
disposition: "completed" | "awaiting-restart"
|
||||
): Promise<{ phase: string; sawDisconnect: boolean } | null> => {
|
||||
let block: { phase: string; sawDisconnect: boolean } | null = {
|
||||
phase: "mutating",
|
||||
sawDisconnect: false,
|
||||
};
|
||||
for (const command of buildMutationSideEffectCommands({ disposition })) {
|
||||
if (command.kind === "reload-agents") {
|
||||
commandLog.push("reload");
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "clear-mutation-block") {
|
||||
commandLog.push("clear");
|
||||
block = null;
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "set-mobile-pane") {
|
||||
commandLog.push(`pane:${command.pane}`);
|
||||
continue;
|
||||
}
|
||||
commandLog.push(`patch:${command.patch.phase}`);
|
||||
block = {
|
||||
phase: command.patch.phase,
|
||||
sawDisconnect: command.patch.sawDisconnect,
|
||||
};
|
||||
}
|
||||
return block;
|
||||
};
|
||||
|
||||
const completedBlock = await runCommands("completed");
|
||||
const awaitingBlock = await runCommands("awaiting-restart");
|
||||
|
||||
expect(completedBlock).toBeNull();
|
||||
expect(awaitingBlock).toEqual({
|
||||
phase: "awaiting-restart",
|
||||
sawDisconnect: false,
|
||||
});
|
||||
expect(commandLog).toEqual(["reload", "clear", "pane:chat", "patch:awaiting-restart"]);
|
||||
});
|
||||
|
||||
it("workflow errors clear block and set page error message", async () => {
|
||||
let block: { phase: string; sawDisconnect: boolean } | null = {
|
||||
phase: "mutating",
|
||||
sawDisconnect: false,
|
||||
};
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
try {
|
||||
await runConfigMutationWorkflow(
|
||||
{ kind: "rename-agent", isLocalGateway: false },
|
||||
{
|
||||
executeMutation: async () => {
|
||||
throw new Error("rename exploded");
|
||||
},
|
||||
shouldAwaitRemoteRestart: async () => false,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
block = null;
|
||||
errorMessage = buildConfigMutationFailureMessage({
|
||||
kind: "rename-agent",
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
expect(block).toBeNull();
|
||||
expect(errorMessage).toBe("rename exploded");
|
||||
});
|
||||
|
||||
it("preserves queue gating when restart block is active", () => {
|
||||
expect(
|
||||
shouldStartNextConfigMutation({
|
||||
status: "connected",
|
||||
hasRunningAgents: false,
|
||||
nextMutationRequiresIdleAgents: false,
|
||||
hasActiveMutation: false,
|
||||
hasRestartBlockInProgress: true,
|
||||
queuedCount: 1,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldStartNextConfigMutation({
|
||||
status: "connected",
|
||||
hasRunningAgents: false,
|
||||
nextMutationRequiresIdleAgents: false,
|
||||
hasActiveMutation: false,
|
||||
hasRestartBlockInProgress: false,
|
||||
queuedCount: 1,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves lock-status text behavior across queued, mutating, and awaiting-restart phases", () => {
|
||||
expect(
|
||||
resolveConfigMutationStatusLine({
|
||||
block: { phase: "queued", sawDisconnect: false },
|
||||
status: "connected",
|
||||
})
|
||||
).toBe("Waiting for active runs to finish");
|
||||
|
||||
expect(
|
||||
resolveConfigMutationStatusLine({
|
||||
block: { phase: "mutating", sawDisconnect: false },
|
||||
status: "connected",
|
||||
})
|
||||
).toBe("Submitting config change");
|
||||
|
||||
expect(
|
||||
resolveConfigMutationStatusLine({
|
||||
block: { phase: "awaiting-restart", sawDisconnect: false },
|
||||
status: "connected",
|
||||
})
|
||||
).toBe("Waiting for gateway to restart");
|
||||
|
||||
expect(
|
||||
resolveConfigMutationStatusLine({
|
||||
block: { phase: "awaiting-restart", sawDisconnect: true },
|
||||
status: "disconnected",
|
||||
})
|
||||
).toBe("Gateway restart in progress");
|
||||
|
||||
expect(
|
||||
resolveConfigMutationStatusLine({
|
||||
block: { phase: "awaiting-restart", sawDisconnect: true },
|
||||
status: "connected",
|
||||
})
|
||||
).toBe("Gateway is back online, syncing agents");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user