Compare commits

...
Author SHA1 Message Date
dev-driver 2e622218dd feat(cli): add or remove one typed edge on an existing rig
No verb could add an edge to a rig that already exists. `rig edge add
<rig-id> <from> <to> --kind <kind>` adds exactly one edge between two
seats by logical ID, and `rig edge remove <rig-id> <edge-id>` removes
exactly the edge an add returned. Both take --plan, which writes nothing,
and --json.

Adding an edge that is already there changes nothing and returns the
existing edge's ID. A delegates_to or spawned_by edge that would make
launch order cycle is refused, since launch and restore refuse a cycle.
Only the edges table changes: no node, session, ID or other edge.

The receipt says restore orders seats from the latest snapshot, so
`rig snapshot <rig-id>` is the step that carries the edge into restore.

The ops are add_edge and remove_edge on the topology converge spine,
behind POST and DELETE /api/rigs/:rigId/edges. diffTopology still
compares members only, so nothing applies an edge on its own.
2026-10-08 19:26:39 +00:00
10 changed files with 527 additions and 7 deletions

No files matched your search

+8
View File
@@ -763,6 +763,14 @@ Root: `rig`; declared option: `-V, --version`.
|---|---|---|
| `rig grow <rig-id> <members...>` | — | `--pod <pod>`<br>`--new-pod <pod>`<br>`--runtime <runtime>`<br>`--cwd <path>`<br>`--json` |
### edge
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig edge` | — | — |
| `rig edge add <rig-id> <from> <to>` | — | `--kind <kind>` **required**<br>`--plan`<br>`--json` |
| `rig edge remove <rig-id> <edge-id>` | — | `--plan`<br>`--json` |
### reconcile-session
| Invocation | Aliases | Declared options |
+100
View File
@@ -0,0 +1,100 @@
import { Command } from "commander";
import { DaemonClient, DaemonResponseError, type DaemonResponse } from "../client.js";
import { getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
interface EdgeEnd {
logicalId: string;
nodeId: string;
}
type EdgeOpResult =
| {
ok: true;
rigId: string;
outcome: "would_add" | "added" | "present" | "would_remove" | "removed";
edge: { id?: string; kind: string; from: EdgeEnd; to: EdgeEnd };
rollback?: string;
}
| { ok: false; code: string; message: string };
const NO_EDGE_ROUTE = "This daemon has no edge route. Upgrade it to a version with `rig edge`, then retry.";
export function edgeCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("edge").description("Add or remove one typed edge between two seats of an existing rig");
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
const run = async (opts: { json?: boolean }, send: (client: DaemonClient) => Promise<DaemonResponse<EdgeOpResult>>) => {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (!daemonStatusGuard(status)) return;
let result: EdgeOpResult;
try {
result = (await send(deps.clientFactory(getDaemonUrl(status)))).data;
} catch (err) {
// A daemon from before this route answers its plain 404 page, which isn't JSON.
if (!(err instanceof DaemonResponseError) || err.status !== 404) throw err;
result = { ok: false, code: "edge_route_missing", message: NO_EDGE_ROUTE };
}
if (opts.json) console.log(JSON.stringify(result, null, 2));
else if (!result.ok) console.error(`${result.code}: ${result.message}`);
else {
const { edge } = result;
const line = `${edge.kind} ${edge.from.logicalId} -> ${edge.to.logicalId}`;
const id = edge.id ? ` edge ${edge.id}` : "";
switch (result.outcome) {
case "would_add":
console.log(`plan add ${line} in rig ${result.rigId}`);
console.log(` from node ${edge.from.nodeId}, to node ${edge.to.nodeId}`);
console.log(" nothing written (--plan)");
break;
case "added":
console.log(`added ${line}${id}`);
console.log(` restore orders seats from the latest snapshot: run \`rig snapshot ${result.rigId}\` to include this edge`);
console.log(` rollback: ${result.rollback}`);
break;
case "present":
console.log(`present ${line}${id} (already there; nothing written)`);
break;
case "would_remove":
console.log(`plan remove ${line}${id} from rig ${result.rigId}`);
console.log(" nothing written (--plan)");
break;
case "removed":
console.log(`removed ${line}${id}`);
break;
}
}
if (!result.ok) process.exitCode = 1;
};
cmd
.command("add")
.description("Add one edge; adding an edge that already exists changes nothing")
.argument("<rig-id>", "Target rig ID")
.argument("<from>", "Source seat logical ID, such as orch.lead")
.argument("<to>", "Target seat logical ID, such as dev.qa")
.requiredOption("--kind <kind>", "delegates_to, spawned_by, can_observe, collaborates_with or escalates_to")
.option("--plan", "Show what would change and write nothing")
.option("--json", "JSON output for agents")
.action((rigId: string, from: string, to: string, opts: { kind: string; plan?: boolean; json?: boolean }) =>
run(opts, (client) => client.post<EdgeOpResult>(
`/api/rigs/${encodeURIComponent(rigId)}/edges`,
{ from, to, kind: opts.kind, plan: opts.plan === true },
)));
cmd
.command("remove")
.description("Remove exactly one edge, by the ID that `rig edge add` returned")
.argument("<rig-id>", "Target rig ID")
.argument("<edge-id>", "Edge ID")
.option("--plan", "Show what would change and write nothing")
.option("--json", "JSON output for agents")
.action((rigId: string, edgeId: string, opts: { plan?: boolean; json?: boolean }) =>
run(opts, (client) => client.delete<EdgeOpResult>(
`/api/rigs/${encodeURIComponent(rigId)}/edges/${encodeURIComponent(edgeId)}${opts.plan ? "?plan=1" : ""}`,
)));
return cmd;
}
+3
View File
@@ -61,6 +61,7 @@ import { expandCommand } from "./commands/expand.js";
import { addMemberCommand } from "./commands/add.js";
import { createCommand } from "./commands/create.js";
import { growCommand } from "./commands/grow.js";
import { edgeCommand } from "./commands/edge.js";
import { reconcileSessionCommand } from "./commands/reconcile-session.js";
import { envCommand } from "./commands/env.js";
import { askCommand } from "./commands/ask.js";
@@ -145,6 +146,7 @@ export interface ProgramDeps {
addDeps?: StatusDeps;
createDeps?: StatusDeps;
growDeps?: StatusDeps;
edgeDeps?: StatusDeps;
reconcileSessionDeps?: StatusDeps;
envDeps?: StatusDeps;
unclaimDeps?: StatusDeps;
@@ -244,6 +246,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(addMemberCommand(depsOverride?.addDeps));
program.addCommand(createCommand(depsOverride?.createDeps));
program.addCommand(growCommand(depsOverride?.growDeps));
program.addCommand(edgeCommand(depsOverride?.edgeDeps));
program.addCommand(reconcileSessionCommand(depsOverride?.reconcileSessionDeps));
program.addCommand(envCommand(depsOverride?.envDeps));
program.addCommand(unclaimCommand(depsOverride?.unclaimDeps));
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from "vitest";
import { DaemonResponseError, type DaemonClient } from "../src/client.js";
import { STATE_FILE, type DaemonState, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import { createProgram } from "../src/index.js";
import type { StatusDeps } from "../src/commands/status.js";
function lifecycleDeps(): LifecycleDeps {
const state: DaemonState = { pid: 123, port: 7433, db: "test.sqlite", startedAt: "2026-10-08T00:00:00Z" };
return {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn((path: string) => path === STATE_FILE ? JSON.stringify(state) : null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn((path: string) => path === STATE_FILE),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
};
}
async function run(client: Partial<DaemonClient>, args: string[]): Promise<{ out: string[]; err: string[]; exitCode: number | undefined }> {
const out: string[] = [];
const err: string[] = [];
const oldLog = console.log;
const oldError = console.error;
const oldExitCode = process.exitCode;
process.exitCode = undefined;
console.log = (...parts: unknown[]) => out.push(parts.join(" "));
console.error = (...parts: unknown[]) => err.push(parts.join(" "));
try {
const deps: StatusDeps = { lifecycleDeps: lifecycleDeps(), clientFactory: () => client as DaemonClient };
await createProgram({ edgeDeps: deps }).parseAsync(["node", "rig", "edge", ...args]);
} finally {
console.log = oldLog;
console.error = oldError;
}
const exitCode = process.exitCode;
process.exitCode = oldExitCode;
return { out, err, exitCode };
}
const edge = {
id: "edge-1",
kind: "delegates_to",
from: { logicalId: "orch.lead", nodeId: "node-a" },
to: { logicalId: "dev.qa", nodeId: "node-b" },
};
describe("rig edge", () => {
it("adds one edge and prints its receipt, the snapshot step and the rollback", async () => {
const post = vi.fn(async () => ({ status: 201, data: { ok: true, rigId: "rig-1", outcome: "added", edge, rollback: "rig edge remove rig-1 edge-1" } }));
const result = await run({ post } as never, ["add", "rig-1", "orch.lead", "dev.qa", "--kind", "delegates_to"]);
expect(post).toHaveBeenCalledWith("/api/rigs/rig-1/edges", { from: "orch.lead", to: "dev.qa", kind: "delegates_to", plan: false });
expect(result.exitCode).toBeUndefined();
expect(result.out).toEqual([
"added delegates_to orch.lead -> dev.qa edge edge-1",
" restore orders seats from the latest snapshot: run `rig snapshot rig-1` to include this edge",
" rollback: rig edge remove rig-1 edge-1",
]);
});
it("sends plan: true and prints the JSON receipt for a plan", async () => {
const { id: _id, ...planned } = edge;
const post = vi.fn(async () => ({ status: 200, data: { ok: true, rigId: "rig-1", outcome: "would_add", edge: planned } }));
const result = await run({ post } as never, ["add", "rig-1", "orch.lead", "dev.qa", "--kind", "delegates_to", "--plan", "--json"]);
expect(post).toHaveBeenCalledWith("/api/rigs/rig-1/edges", { from: "orch.lead", to: "dev.qa", kind: "delegates_to", plan: true });
expect(JSON.parse(result.out.join("\n"))).toMatchObject({ ok: true, outcome: "would_add", edge: planned });
expect(result.exitCode).toBeUndefined();
});
it("reports an edge that is already there as a success that wrote nothing", async () => {
const post = vi.fn(async () => ({ status: 200, data: { ok: true, rigId: "rig-1", outcome: "present", edge } }));
const result = await run({ post } as never, ["add", "rig-1", "orch.lead", "dev.qa", "--kind", "delegates_to"]);
expect(result.out).toEqual(["present delegates_to orch.lead -> dev.qa edge edge-1 (already there; nothing written)"]);
expect(result.exitCode).toBeUndefined();
});
it("prints a refusal with its code and exits 1", async () => {
const post = vi.fn(async () => ({ status: 400, data: { ok: false, code: "edge_cycle", message: "a delegates_to edge from dev.qa to orch.lead would make launch order cycle" } }));
const result = await run({ post } as never, ["add", "rig-1", "dev.qa", "orch.lead", "--kind", "delegates_to"]);
expect(result.err).toEqual(["edge_cycle: a delegates_to edge from dev.qa to orch.lead would make launch order cycle"]);
expect(result.exitCode).toBe(1);
});
it("names the upgrade when the daemon predates the edge route", async () => {
const post = vi.fn(async () => { throw new DaemonResponseError(404, "404 Not Found"); });
const result = await run({ post } as never, ["add", "rig-1", "orch.lead", "dev.qa", "--kind", "delegates_to", "--json"]);
expect(JSON.parse(result.out.join("\n"))).toMatchObject({ ok: false, code: "edge_route_missing" });
expect(result.exitCode).toBe(1);
});
it("removes exactly one edge by ID, and plans a removal without writing", async () => {
const del = vi.fn(async (path: string) => ({
status: 200,
data: { ok: true, rigId: "rig-1", outcome: path.endsWith("?plan=1") ? "would_remove" : "removed", edge },
}));
const planned = await run({ delete: del } as never, ["remove", "rig-1", "edge-1", "--plan"]);
expect(del).toHaveBeenLastCalledWith("/api/rigs/rig-1/edges/edge-1?plan=1");
expect(planned.out[0]).toBe("plan remove delegates_to orch.lead -> dev.qa edge edge-1 from rig rig-1");
const removed = await run({ delete: del } as never, ["remove", "rig-1", "edge-1"]);
expect(del).toHaveBeenLastCalledWith("/api/rigs/rig-1/edges/edge-1");
expect(removed.out).toEqual(["removed delegates_to orch.lead -> dev.qa edge edge-1"]);
expect(removed.exitCode).toBeUndefined();
});
});
+120
View File
@@ -0,0 +1,120 @@
import type { RigRepository } from "./rig-repository.js";
import type { Edge, NodeWithBinding } from "./types.js";
import { VALID_EDGE_KINDS } from "./rigspec-schema.js";
/**
* Add or remove one typed edge on an existing rig. The operator chooses the edge; this applies exactly that
* change and returns a receipt naming it, so a repeat is a no-op and the rollback removes only what was added.
* It changes the edges table and nothing else: no node, session, ID or other edge.
*/
/** The kinds that order launch and restore: delegates_to launches its source first, spawned_by its target first. */
const LAUNCH_DEPENDENCY_KINDS = new Set(["delegates_to", "spawned_by"]);
export interface EdgeEnd {
logicalId: string;
nodeId: string;
}
export interface EdgeReceipt {
id?: string;
kind: string;
from: EdgeEnd;
to: EdgeEnd;
}
export type EdgeOpOutcome =
| {
ok: true;
rigId: string;
outcome: "would_add" | "added" | "present" | "would_remove" | "removed";
edge: EdgeReceipt;
/** On "added": the command that removes exactly this edge. */
rollback?: string;
}
| {
ok: false;
code: "rig_not_found" | "node_not_found" | "invalid_kind" | "edge_cycle" | "edge_not_found" | "unavailable";
message: string;
};
function end(node: NodeWithBinding): EdgeEnd {
return { logicalId: node.logicalId, nodeId: node.id };
}
function launchArc(edge: Pick<Edge, "sourceId" | "targetId" | "kind">): [first: string, then: string] {
return edge.kind === "delegates_to" ? [edge.sourceId, edge.targetId] : [edge.targetId, edge.sourceId];
}
/** True when adding `candidate` to `edges` would make launch order cycle, which fails launch and restore. */
function createsLaunchCycle(edges: Edge[], candidate: Pick<Edge, "sourceId" | "targetId" | "kind">): boolean {
const [first, then] = launchArc(candidate);
const next = new Map<string, string[]>();
for (const edge of edges) {
if (!LAUNCH_DEPENDENCY_KINDS.has(edge.kind)) continue;
const [a, b] = launchArc(edge);
next.set(a, [...(next.get(a) ?? []), b]);
}
const seen = new Set<string>();
const queue = [then];
while (queue.length > 0) {
const node = queue.pop()!;
if (node === first) return true;
if (seen.has(node)) continue;
seen.add(node);
queue.push(...(next.get(node) ?? []));
}
return false;
}
export function addRigEdge(
repo: RigRepository,
input: { rigId: string; from: string; to: string; kind: string; plan?: boolean },
): EdgeOpOutcome {
const rig = repo.getRig(input.rigId);
if (!rig) return { ok: false, code: "rig_not_found", message: `rig ${input.rigId} not found` };
if (!VALID_EDGE_KINDS.has(input.kind)) {
return { ok: false, code: "invalid_kind", message: `kind must be one of ${[...VALID_EDGE_KINDS].join(", ")} (got "${input.kind}")` };
}
const from = rig.nodes.find((node) => node.logicalId === input.from);
const to = rig.nodes.find((node) => node.logicalId === input.to);
if (!from || !to) {
const missing = [...new Set([from ? null : input.from, to ? null : input.to].filter((id): id is string => id !== null))];
return { ok: false, code: "node_not_found", message: `no seat with logical id ${missing.map((id) => `'${id}'`).join(" or ")} in rig ${input.rigId}` };
}
const edge: EdgeReceipt = { kind: input.kind, from: end(from), to: end(to) };
const existing = rig.edges.find((e) => e.sourceId === from.id && e.targetId === to.id && e.kind === input.kind);
if (existing) return { ok: true, rigId: input.rigId, outcome: "present", edge: { ...edge, id: existing.id } };
const candidate = { sourceId: from.id, targetId: to.id, kind: input.kind };
if (LAUNCH_DEPENDENCY_KINDS.has(input.kind) && createsLaunchCycle(rig.edges, candidate)) {
return {
ok: false,
code: "edge_cycle",
message: `a ${input.kind} edge from ${input.from} to ${input.to} would make launch order cycle, and launch and restore refuse a cycle`,
};
}
if (input.plan) return { ok: true, rigId: input.rigId, outcome: "would_add", edge };
const added = repo.addEdge(input.rigId, from.id, to.id, input.kind);
return {
ok: true,
rigId: input.rigId,
outcome: "added",
edge: { ...edge, id: added.id },
rollback: `rig edge remove ${input.rigId} ${added.id}`,
};
}
export function removeRigEdge(
repo: RigRepository,
input: { rigId: string; edgeId: string; plan?: boolean },
): EdgeOpOutcome {
const rig = repo.getRig(input.rigId);
if (!rig) return { ok: false, code: "rig_not_found", message: `rig ${input.rigId} not found` };
const edge = rig.edges.find((e) => e.id === input.edgeId);
if (!edge) return { ok: false, code: "edge_not_found", message: `no edge ${input.edgeId} in rig ${input.rigId}` };
const node = (id: string) => rig.nodes.find((n) => n.id === id)!;
const receipt: EdgeReceipt = { id: edge.id, kind: edge.kind, from: end(node(edge.sourceId)), to: end(node(edge.targetId)) };
if (input.plan) return { ok: true, rigId: input.rigId, outcome: "would_remove", edge: receipt };
repo.removeEdge(edge.id);
return { ok: true, rigId: input.rigId, outcome: "removed", edge: receipt };
}
@@ -504,6 +504,11 @@ export class RigRepository {
);
}
/** Removes exactly one edge row; true when it existed. */
removeEdge(edgeId: string): boolean {
return this.db.prepare("DELETE FROM edges WHERE id = ?").run(edgeId).changes > 0;
}
getRig(rigId: string): RigWithRelations | null {
const rigRow = this.db
.prepare("SELECT * FROM rigs WHERE id = ?")
@@ -1,5 +1,7 @@
import type { PodRigInstantiator, AddMemberOutcome } from "./rigspec-instantiator.js";
import type { ClaimService, ReconcileSessionOutcome } from "./claim-service.js";
import type { RigRepository } from "./rig-repository.js";
import { addRigEdge, removeRigEdge, type EdgeOpOutcome } from "./rig-edge-ops.js";
/**
* Topology-mutation converge spine (OPR.0.3.3.24, AC-6 scaffold).
@@ -30,12 +32,17 @@ export type TopologyOp =
| { kind: "remove_member"; logicalId: string }
| { kind: "move_member"; logicalId: string; toPod: string }
| { kind: "fork_member"; logicalId: string; toMember: string }
| { kind: "change_runtime"; logicalId: string; runtime: string };
| { kind: "change_runtime"; logicalId: string; runtime: string }
// One typed edge on an existing rig, between two seats by logical id. Imperative, like reconcile_session:
// diffTopology compares members only, so it never emits these; an operator applies exactly the edge it chose,
// and removes one by the id the add returned.
| { kind: "add_edge"; from: string; to: string; edgeKind: string; plan?: boolean }
| { kind: "remove_edge"; edgeId: string; plan?: boolean };
export type TopologyOpKind = TopologyOp["kind"];
/** The op-kinds converge() implements this release. The rest are classified-deferred. */
export const SUPPORTED_OP_KINDS: readonly TopologyOpKind[] = ["add_member", "reconcile_session"];
export const SUPPORTED_OP_KINDS: readonly TopologyOpKind[] = ["add_member", "reconcile_session", "add_edge", "remove_edge"];
export function isSupportedOpKind(kind: TopologyOpKind): boolean {
return SUPPORTED_OP_KINDS.includes(kind);
@@ -47,6 +54,7 @@ export const DEFERRED_OP_REASON = "detected, not yet supported in this release";
export type ConvergeResult =
| { kind: "add_member"; supported: true; outcome: AddMemberOutcome }
| { kind: "reconcile_session"; supported: true; outcome: ReconcileSessionOutcome }
| { kind: "add_edge" | "remove_edge"; supported: true; outcome: EdgeOpOutcome }
| { kind: TopologyOpKind; detected: true; supported: false; reason: string };
/** A member as DECLARED in the desired spec (one pod-scoped member fragment). */
@@ -109,6 +117,8 @@ export interface ConvergeDeps {
instantiator: PodRigInstantiator;
/** Required for reconcile_session ops; add_member-only callers may omit it. */
claimService?: ClaimService;
/** Required for add_edge and remove_edge ops. */
rigRepo?: RigRepository;
}
/**
@@ -151,6 +161,16 @@ export async function convergeOp(
});
return { kind: "reconcile_session", supported: true, outcome };
}
case "add_edge":
case "remove_edge": {
if (!deps.rigRepo) {
return { kind: op.kind, supported: true, outcome: { ok: false, code: "unavailable", message: "Rig repository unavailable; cannot change edges." } };
}
const outcome = op.kind === "add_edge"
? addRigEdge(deps.rigRepo, { rigId, from: op.from, to: op.to, kind: op.edgeKind, plan: op.plan })
: removeRigEdge(deps.rigRepo, { rigId, edgeId: op.edgeId, plan: op.plan });
return { kind: op.kind, supported: true, outcome };
}
case "remove_member":
case "move_member":
case "fork_member":
+37
View File
@@ -36,6 +36,7 @@ import type { Pod, ExpansionPodFragment } from "../domain/types.js";
import type { RigExpansionService } from "../domain/rig-expansion-service.js";
import type { PodRigInstantiator } from "../domain/rigspec-instantiator.js";
import { convergeOp } from "../domain/topology-converge.js";
import type { EdgeOpOutcome } from "../domain/rig-edge-ops.js";
import type { RigLifecycleService } from "../domain/rig-lifecycle-service.js";
import type { SelfAttachService } from "../domain/self-attach-service.js";
@@ -842,6 +843,42 @@ rigsRoutes.post("/:rigId/pods/:podNamespace/members", async (c) => {
return c.json(outcome, 201);
});
function edgeOpStatus(outcome: EdgeOpOutcome): 200 | 201 | 400 | 404 | 500 {
if (outcome.ok) return outcome.outcome === "added" ? 201 : 200;
if (outcome.code === "rig_not_found" || outcome.code === "edge_not_found") return 404;
return outcome.code === "unavailable" ? 500 : 400;
}
// POST /api/rigs/:rigId/edges — add one typed edge between two seats, by logical id. `plan: true` writes nothing.
rigsRoutes.post("/:rigId/edges", async (c) => {
const rigId = c.req.param("rigId")!;
const body: Record<string, unknown> = await c.req.json().catch(() => ({}));
const { from, to, kind } = body;
if (typeof from !== "string" || typeof to !== "string" || typeof kind !== "string") {
return c.json({ ok: false, code: "validation_failed", message: "from, to and kind are required strings" }, 400);
}
const converged = await convergeOp(
{ instantiator: c.get("podInstantiator" as never) as PodRigInstantiator, rigRepo: getRepo(c) },
rigId,
{ kind: "add_edge", from, to, edgeKind: kind, plan: body["plan"] === true },
".",
);
if (converged.kind !== "add_edge" || !converged.supported) return c.json({ error: "Unexpected converge result for add_edge" }, 500);
return c.json(converged.outcome, edgeOpStatus(converged.outcome));
});
// DELETE /api/rigs/:rigId/edges/:edgeId — remove exactly that edge. `?plan=1` writes nothing.
rigsRoutes.delete("/:rigId/edges/:edgeId", async (c) => {
const converged = await convergeOp(
{ instantiator: c.get("podInstantiator" as never) as PodRigInstantiator, rigRepo: getRepo(c) },
c.req.param("rigId")!,
{ kind: "remove_edge", edgeId: c.req.param("edgeId")!, plan: c.req.query("plan") === "1" },
".",
);
if (converged.kind !== "remove_edge" || !converged.supported) return c.json({ error: "Unexpected converge result for remove_edge" }, 500);
return c.json(converged.outcome, edgeOpStatus(converged.outcome));
});
// DELETE /api/rigs/:rigId/pods/:podRef
rigsRoutes.delete("/:rigId/pods/:podRef", async (c) => {
const rigId = c.req.param("rigId")!;
+118
View File
@@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type Database from "better-sqlite3";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
// One typed edge on an existing rig, through the real route, converge op and repository.
describe("POST and DELETE /api/rigs/:rigId/edges", () => {
let db: Database.Database;
let setup: ReturnType<typeof createTestApp>;
let rigId: string;
beforeEach(() => {
db = createFullTestDb();
setup = createTestApp(db);
rigId = setup.rigRepo.createRig("edge-rig").id;
const lead = setup.rigRepo.addNode(rigId, "orch.lead");
const build = setup.rigRepo.addNode(rigId, "dev.build");
setup.rigRepo.addNode(rigId, "dev.qa");
const review = setup.rigRepo.addNode(rigId, "review.r1");
// Edges the rig already has, which no edge operation may touch.
setup.rigRepo.addEdge(rigId, lead.id, build.id, "delegates_to");
setup.rigRepo.addEdge(rigId, lead.id, review.id, "can_observe");
});
afterEach(() => { db.close(); });
const add = (body: Record<string, unknown>, rig = rigId) => setup.app.request(`/api/rigs/${rig}/edges`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const remove = (edgeId: string, plan = false, rig = rigId) =>
setup.app.request(`/api/rigs/${rig}/edges/${edgeId}${plan ? "?plan=1" : ""}`, { method: "DELETE" });
const state = () => {
const rig = setup.rigRepo.getRig(rigId)!;
return { nodes: rig.nodes, edges: [...rig.edges].sort((a, b) => a.id.localeCompare(b.id)) };
};
it("plans without writing, adds one edge, and repeats as a no-op with the same receipt", async () => {
const before = state();
const planned = await add({ from: "orch.lead", to: "dev.qa", kind: "delegates_to", plan: true });
expect(planned.status).toBe(200);
expect(await planned.json()).toMatchObject({ ok: true, outcome: "would_add", edge: { kind: "delegates_to", from: { logicalId: "orch.lead" }, to: { logicalId: "dev.qa" } } });
expect(state()).toEqual(before);
const added = await add({ from: "orch.lead", to: "dev.qa", kind: "delegates_to" });
expect(added.status).toBe(201);
const receipt = await added.json() as { outcome: string; edge: { id: string }; rollback: string };
expect(receipt).toMatchObject({ ok: true, outcome: "added", rollback: `rig edge remove ${rigId} ${receipt.edge.id}` });
const after = state();
expect(after.nodes).toEqual(before.nodes);
expect(after.edges.filter((edge) => edge.id !== receipt.edge.id)).toEqual(before.edges);
expect(after.edges).toHaveLength(before.edges.length + 1);
const again = await add({ from: "orch.lead", to: "dev.qa", kind: "delegates_to" });
expect(again.status).toBe(200);
expect(await again.json()).toMatchObject({ ok: true, outcome: "present", edge: { id: receipt.edge.id } });
expect(state()).toEqual(after);
const otherKind = await add({ from: "dev.build", to: "dev.qa", kind: "collaborates_with" });
expect(otherKind.status).toBe(201);
expect(state().edges).toHaveLength(before.edges.length + 2);
});
it("rolls back only the edge it added, and a second removal finds nothing", async () => {
const before = state();
const added = await (await add({ from: "orch.lead", to: "dev.qa", kind: "delegates_to" })).json() as { edge: { id: string } };
const planned = await remove(added.edge.id, true);
expect(planned.status).toBe(200);
expect(await planned.json()).toMatchObject({ ok: true, outcome: "would_remove", edge: { id: added.edge.id } });
expect(state().edges).toHaveLength(before.edges.length + 1);
const removed = await remove(added.edge.id);
expect(removed.status).toBe(200);
expect(await removed.json()).toMatchObject({ ok: true, outcome: "removed", edge: { id: added.edge.id, kind: "delegates_to", from: { logicalId: "orch.lead" }, to: { logicalId: "dev.qa" } } });
expect(state()).toEqual(before);
const again = await remove(added.edge.id);
expect(again.status).toBe(404);
expect(await again.json()).toMatchObject({ ok: false, code: "edge_not_found" });
expect(state()).toEqual(before);
});
it("refuses a launch-order cycle in either launch kind, and writes nothing", async () => {
const before = state();
for (const body of [
{ from: "dev.build", to: "orch.lead", kind: "delegates_to" },
{ from: "orch.lead", to: "dev.build", kind: "spawned_by" },
{ from: "dev.qa", to: "dev.qa", kind: "delegates_to" },
]) {
const refused = await add(body);
expect(refused.status).toBe(400);
expect(await refused.json()).toMatchObject({ ok: false, code: "edge_cycle" });
}
expect(state()).toEqual(before);
// A kind that doesn't order launch can run against the delegation.
expect((await add({ from: "dev.build", to: "orch.lead", kind: "collaborates_with" })).status).toBe(201);
});
it("refuses an unknown rig, seat or kind, and a malformed body, without writing", async () => {
const before = state();
const cases: Array<[Response, number, string]> = [
[await add({ from: "orch.lead", to: "dev.qa", kind: "delegates_to" }, "no-such-rig"), 404, "rig_not_found"],
[await add({ from: "orch.lead", to: "dev.absent", kind: "delegates_to" }), 400, "node_not_found"],
[await add({ from: "orch.lead", to: "dev.qa", kind: "reports_to" }), 400, "invalid_kind"],
[await add({ from: "orch.lead", kind: "delegates_to" }), 400, "validation_failed"],
[await remove("no-such-edge"), 404, "edge_not_found"],
];
for (const [response, status, code] of cases) {
expect(response.status).toBe(status);
expect(await response.json()).toMatchObject({ ok: false, code });
}
const unknown = await (await add({ from: "orch.lead", to: "dev.absent", kind: "delegates_to" })).json() as { message: string };
expect(unknown.message).toContain("'dev.absent'");
expect(state()).toEqual(before);
});
});
@@ -67,11 +67,13 @@ describe("topology-converge", () => {
describe("supported-op-kind set", () => {
// OPR.0.3.4.3 grew the supported set: add_member (slice 24) +
// reconcile_session (the no-launch adopt). Identity-migrating kinds stay
// classified-deferred to the 0.4.0 identity theme.
it("supports exactly add_member + reconcile_session", () => {
expect(SUPPORTED_OP_KINDS).toEqual(["add_member", "reconcile_session"]);
expect(isSupportedOpKind("add_member")).toBe(true);
expect(isSupportedOpKind("reconcile_session")).toBe(true);
// classified-deferred to the 0.4.0 identity theme. add_edge and remove_edge
// change one typed edge and no seat identity.
it("supports exactly add_member, reconcile_session, add_edge and remove_edge", () => {
expect(SUPPORTED_OP_KINDS).toEqual(["add_member", "reconcile_session", "add_edge", "remove_edge"]);
for (const k of ["add_member", "reconcile_session", "add_edge", "remove_edge"] as const) {
expect(isSupportedOpKind(k)).toBe(true);
}
for (const k of ["remove_member", "move_member", "fork_member", "change_runtime"] as const) {
expect(isSupportedOpKind(k)).toBe(false);
}