mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 01:41:39 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e622218dd |
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
@@ -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")!;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user