Compare commits

..
Author SHA1 Message Date
dev60-review f860c2ffe0 chore(release): prepare 0.7.13
Set 0.7.13 in the root, cli, daemon, ui and acp packages and the lockfile,
the ACP launcher's @openrig/cli dependency and registry listing, and the
openrig plugin's four manifests, which carry the CLI's release version.

The install commands on the README, getting started and the quickstart name
v0.7.13: the rigs skill and the install script. Getting started and the
quickstart lead with the command for a new install and keep the matching
rig --version tag second, for an existing one.

The packaged-plugin baseline stays bound to published 0.7.12, and
openrig-core stays 0.1.29, above the published 0.1.28 for its changed hooks.
2026-11-12 11:31:20 +00:00
24 changed files with 54 additions and 569 deletions

No files matched your search

+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.13/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.13/skills/rigs --skill rigs --agent codex
```
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
@@ -68,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
### Install it yourself
This runs OpenRig's install script from the `v0.7.11` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
This runs OpenRig's install script from the `v0.7.13` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.13/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.13/scripts/install.sh | sh
```
If the last step isn't ready, the installer reports `INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any reported Fix, then rerun `rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider sign-ins, sign in to each selected provider as below and continue.
+1 -1
View File
@@ -245,7 +245,7 @@ the sections that were not read are `null`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig import <path>` | — | `--instantiate`<br>`--materialize-only`<br>`--operation-id <uuid>`<br>`--json`<br>`--workspace-only`<br>`--preflight`<br>`--target-rig <rigId>`<br>`--rig-root <root>`<br>`--cwd <path>` |
| `rig import <path>` | — | `--instantiate`<br>`--materialize-only`<br>`--workspace-only`<br>`--preflight`<br>`--target-rig <rigId>`<br>`--rig-root <root>`<br>`--cwd <path>` |
### ui
+11 -9
View File
@@ -180,20 +180,22 @@ user.
**Agents:** if you're setting OpenRig up for someone, load the `rigs` skill and follow it. It carries the install, the
OpenRig view and the handoff to the operator as one procedure. From 0.6.7, OpenRig installs it as `rigs` in
`~/.claude/skills` and `~/.agents/skills` when the daemon starts. To add it manually, use the release tag matching
your installed `rig --version`: replace `<release-tag>` below with that tag, including its `v` prefix.
`rig --version` prints the version and a commit, for example `0.6.11 (379f0a0d)`; the tag is `v0.6.11`.
The skill is in the repository from v0.6.6; on an older install, use the v0.6.6 tag or upgrade first.
Choose the command for the agent doing the setup:
`~/.claude/skills` and `~/.agents/skills` when the daemon starts. For a new install, add it with the command for the
agent doing the setup. Both use this release's tag:
```sh
# Claude Code
npx skills add "https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs" --skill rigs --agent claude-code
npx skills add "https://github.com/mvschwarz/openrig/tree/v0.7.13/skills/rigs" --skill rigs --agent claude-code
# Codex
npx skills add "https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs" --skill rigs --agent codex
npx skills add "https://github.com/mvschwarz/openrig/tree/v0.7.13/skills/rigs" --skill rigs --agent codex
```
If OpenRig is already installed, use the release tag matching your installed `rig --version` instead: replace the tag
after `tree/` with it, including its `v` prefix. `rig --version` prints the version and a commit, for example
`0.6.11 (379f0a0d)`; the tag is `v0.6.11`. The skill is in the repository from v0.6.6; on an older install, use the
v0.6.6 tag or upgrade first.
These are the pinned, per-agent forms used on the [rigs pages](https://openrig.dev/rigs).
You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
@@ -201,11 +203,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.13/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.13/scripts/install.sh | sh`, installs the
latest published `@openrig/cli` with `npm install -g`,
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
Codex and may install a missing one, as described below. A failed step prints
+2 -18
View File
@@ -1,25 +1,9 @@
# HTTP operation correlation
`GET /api/operations/capabilities` advertises `operation-correlation.v1` and the supported kinds: `rig.create`, `rig.import`, `rig.materialize`, and `member.add`. A client should check the required kind before relying on correlation; an older daemon may ignore an unfamiliar request header.
`GET /api/operations/capabilities` advertises `operation-correlation.v1` and the supported kinds: `rig.create`, `rig.import`, and `member.add`. A client should check this marker before relying on correlation; an older daemon may ignore an unfamiliar request header.
For `POST /api/rigs`, `POST /api/rigs/import`, `POST /api/rigs/import/materialize`, and `POST /api/rigs/:rigId/pods/:podNamespace/members`, send a UUID in `X-OpenRig-Operation-Id`. Keep the same daemon, route targets, original request text, and behavior-changing headers for a retry. Materialization binds the target rig (when expanding), source root and cwd override as well as the spec text; it is distinct from launching import. IDs are case-normalized. An ID that isn't an RFC UUID receives HTTP 400 `Invalid X-OpenRig-Operation-Id` before the route does anything else. Once a request passes the route's own validation, a different request using the same ID receives HTTP 409 `operation_id_conflict`. Matching compares the exact request text, so a change in whitespace or key order is a different request, not an equivalent one; an exact replay receives HTTP 200 with `replayed: true` and current database state. A first request retains its usual status and response fields and adds `operation`.
For `POST /api/rigs`, `POST /api/rigs/import`, and `POST /api/rigs/:rigId/pods/:podNamespace/members`, send a UUID in `X-OpenRig-Operation-Id`. Keep the same daemon, route targets, original request text, and behavior-changing headers for a retry. IDs are case-normalized. An ID that isn't an RFC UUID receives HTTP 400 `Invalid X-OpenRig-Operation-Id` before the route does anything else. Once a request passes the route's own validation, a different request using the same ID receives HTTP 409 `operation_id_conflict`. Matching compares the exact request text, so a change in whitespace or key order is a different request, not an equivalent one; an exact replay receives HTTP 200 with `replayed: true` and current database state. A first request retains its usual status and response fields and adds `operation`.
The operation contains the rig and node IDs committed by that request. Its `current` field reads the database at response time, including whether recorded resources still exist and the latest recorded session for each node. It does not inspect tmux or prove a launch finished. A reservation with no IDs can mean that execution is still underway or that it failed before creating resources. Replaying an exact request that failed before creating resources returns HTTP 200 with `operation.rigId: null` and `operation.nodeIds: []`; inspect those fields to distinguish it from a replay that recorded resources. Replays never execute the mutation again, retry a launch, or recreate deleted resources. Use a new UUID only when deliberately starting a new execution.
This contract stores neither the original response nor a recovery verdict. An HTTP response can be lost after a database commit. External launch effects are outside the database transaction, so the contract is correlation rather than exactly-once execution of external effects.
## CLI no-launch preparation
`rig import ./team.yaml --materialize-only --operation-id <uuid> --json` prepares topology without starting sessions.
Save the UUID before running the command and reuse the exact command after a lost response. The CLI checks that the
daemon advertises `rig.materialize` before writing; a daemon without that capability refuses the explicit retry request.
`--operation-id` and `--json` apply only to `--materialize-only`, without other import modes. Omitting the operation id
keeps ordinary materialization behavior; it does not make repeated imports idempotent.
JSON output is the native receipt. Both initial and replay receipts carry the same committed ids in
`operation.rigId` and `operation.nodeIds`. Replays report current database state, not a cached first result. An empty
receipt leaves the CLI nonzero: it cannot confirm preparation while the original execution is still running or failed
before creating resources. Text-mode replays still print recorded rig and node IDs when resources are archived or
missing, with a warning and a nonzero exit status. JSON mode retains the native receipt and the same nonzero status.
The receipt's ids do not authorize launching anything; use the normal owner-authorized
[first start of a materialized seat](rig-spec.md#starting-one-materialized-seat) afterward.
+4 -4
View File
@@ -12,13 +12,13 @@ In your project folder, add OpenRig's `rigs` skill to the agent you use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.13/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.13/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.11`. If OpenRig is already installed, use the tag
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
These use this release's tag, for a new install. If OpenRig is already installed, replace the tag after `tree/` with
the one that matches `rig --version` (`v` and its version number), or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
you want built.
-4
View File
@@ -193,10 +193,6 @@ edges:
### Starting one materialized seat
`rig import ./team.yaml --materialize-only` records the team without starting its harnesses.
For an automated preparation that may retry, save a UUID first and pass
`--operation-id <uuid> --json`; keep that same UUID, spec text, source root and other options on a retry.
Both receipts identify the original rig and nodes through `operation`; see
[HTTP operation correlation](operation-correlation.md#cli-no-launch-preparation) for capability checks and incomplete receipts.
For a seat that has never started, use its positional logical ID:
```bash
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.1",
"version": "0.7.13",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7298,7 +7298,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.13",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7336,7 +7336,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.13",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7380,7 +7380,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.13",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.13",
"private": true,
"license": "Apache-2.0",
"type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/acp",
"version": "0.7.1",
"version": "0.7.13",
"description": "OpenRig as an agent in ACP apps: starts rig acp serve from the @openrig/cli it pins.",
"type": "module",
"bin": {
@@ -15,7 +15,7 @@
"node": ">=22"
},
"dependencies": {
"@openrig/cli": "0.7.1"
"@openrig/cli": "0.7.13"
},
"license": "Apache-2.0",
"author": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "openrig",
"name": "OpenRig",
"version": "0.7.1",
"version": "0.7.13",
"description": "Send work to your OpenRig team of agents and follow it through to the result",
"repository": "https://github.com/mvschwarz/openrig",
"website": "https://openrig.dev",
@@ -12,7 +12,7 @@
"license_url": "https://github.com/mvschwarz/openrig/blob/main/LICENSE",
"distribution": {
"npx": {
"package": "@openrig/[email protected]"
"package": "@openrig/[email protected]3"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.13",
"type": "module",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"keywords": [
+5 -96
View File
@@ -1,8 +1,7 @@
import nodePath from "node:path";
import { Command } from "commander";
import fs from "node:fs";
import { z } from "zod";
import { DaemonClient, DaemonResponseError, type DaemonResponse } from "../client.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -13,30 +12,6 @@ export interface ImportDeps extends StatusDeps {
const LONG_RUNNING_IMPORT_TIMEOUT_MS = 120_000;
const materializeOperation = z.object({
operationId: z.uuid(), rigId: z.string().nullable(), nodeIds: z.array(z.string().min(1)),
current: z.object({
rig: z.object({ id: z.string(), exists: z.boolean(), archivedAt: z.string().nullable() }).nullable(),
nodes: z.array(z.object({ id: z.string(), exists: z.boolean() })),
}),
});
const materializeReplay = z.object({ replayed: z.literal(true), operation: materializeOperation });
const materializeResult = z.object({
rigId: z.string(), specName: z.string(), nodes: z.array(z.object({ logicalId: z.string(), status: z.string() })),
});
function recordedMaterializationMatches(operation: z.infer<typeof materializeOperation>): boolean {
// Reservations have no rig id; a committed empty pod has a rig id and zero nodes.
return operation.rigId !== null && operation.rigId.length > 0 && operation.current.rig?.id === operation.rigId &&
new Set(operation.nodeIds).size === operation.nodeIds.length && operation.current.nodes.length === operation.nodeIds.length &&
operation.nodeIds.every(id => operation.current.nodes.some(node => node.id === id));
}
function materializationCurrentlyAvailable(operation: z.infer<typeof materializeOperation>): boolean {
return operation.current.rig?.exists === true && operation.current.rig.archivedAt === null &&
operation.current.nodes.every(node => node.exists);
}
export function importCommand(depsOverride?: ImportDeps): Command {
const cmd = new Command("import").description("Import a rig spec from YAML");
const getDeps = (): ImportDeps => depsOverride ?? {
@@ -49,25 +24,13 @@ export function importCommand(depsOverride?: ImportDeps): Command {
.argument("<path>", "Path to YAML rig spec file")
.option("--instantiate", "Instantiate the rig after import")
.option("--materialize-only", "Create rig topology without launching sessions")
.option("--operation-id <uuid>", "With --materialize-only: keep this UUID for exact retries")
.option("--json", "With --materialize-only: print the native JSON receipt")
.option("--workspace-only", "Apply only the workspace declaration to an existing rig")
.option("--preflight", "Run preflight checks")
.option("--target-rig <rigId>", "Target existing rig for materialization or workspace apply")
.option("--rig-root <root>", "Root directory for pod-aware resolution")
.option("--cwd <path>", "Override launch/materialization working directory for all members")
.action(async (filePath: string, opts: { instantiate?: boolean; materializeOnly?: boolean; operationId?: string; json?: boolean; workspaceOnly?: boolean; preflight?: boolean; targetRig?: string; rigRoot?: string; cwd?: string }) => {
.action(async (filePath: string, opts: { instantiate?: boolean; materializeOnly?: boolean; workspaceOnly?: boolean; preflight?: boolean; targetRig?: string; rigRoot?: string; cwd?: string }) => {
const deps = getDeps();
if ((opts.operationId !== undefined || opts.json) && (!opts.materializeOnly || opts.instantiate || opts.workspaceOnly || opts.preflight)) {
console.error("--operation-id and --json require --materialize-only without other import modes.");
process.exitCode = 1;
return;
}
if (opts.operationId !== undefined && !z.uuid().safeParse(opts.operationId).success) {
console.error("--operation-id must be an RFC UUID.");
process.exitCode = 1;
return;
}
// Read local file first (before daemon check — fail fast on missing file)
let yaml: string;
@@ -163,49 +126,10 @@ export function importCommand(depsOverride?: ImportDeps): Command {
const headers = {
...extraHeaders,
...(opts.targetRig ? { "X-Target-Rig-Id": opts.targetRig } : {}),
...(opts.operationId !== undefined ? { "X-OpenRig-Operation-Id": opts.operationId } : {}),
};
if (opts.operationId !== undefined) {
let capabilities: DaemonResponse<unknown>;
try {
capabilities = await client.get<unknown>("/api/operations/capabilities");
} catch (error) {
if (!(error instanceof DaemonResponseError)) throw error;
console.error("This daemon did not return a readable operation capability response. Nothing was materialized; upgrade the daemon before using --operation-id.");
process.exitCode = 1;
return;
}
const parsed = z.object({ contract: z.literal("operation-correlation.v1"), kinds: z.array(z.string()) }).safeParse(capabilities.data);
if (capabilities.status >= 400 || !parsed.success || !parsed.data.kinds.includes("rig.materialize")) {
console.error("This daemon does not advertise retry-safe materialization. Nothing was materialized; upgrade the daemon before using --operation-id.");
process.exitCode = 1;
return;
}
}
const res = await client.postText<unknown>("/api/rigs/import/materialize", yaml, "text/yaml", headers);
const replay = materializeReplay.safeParse(res.data);
const fresh = materializeResult.safeParse(res.data);
const receipt = z.object({ operation: materializeOperation }).safeParse(res.data);
const confirmed = replay.success ? recordedMaterializationMatches(replay.data.operation) : fresh.success;
// Fresh results expose logical ids, not native node ids: compare the available count,
// and require distinct recorded ids with matching current-node evidence, without inventing a mapping.
const correlated = opts.operationId === undefined || receipt.success && receipt.data.operation.operationId === opts.operationId.toLowerCase() &&
recordedMaterializationMatches(receipt.data.operation) && (replay.success || fresh.success &&
receipt.data.operation.rigId === fresh.data.rigId && receipt.data.operation.nodeIds.length === fresh.data.nodes.length);
const available = !receipt.success || materializationCurrentlyAvailable(receipt.data.operation);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
if (res.status >= 400 || !confirmed || !correlated || !available) process.exitCode = 1;
return;
}
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string; error?: string }>("/api/rigs/import/materialize", yaml, "text/yaml", headers);
if (res.status === 409 || res.status === 400 || res.status === 404) {
const error = z.object({ code: z.string().optional(), errors: z.array(z.string()).optional(), message: z.string().optional(), error: z.string().optional() }).safeParse(res.data);
const data = error.success ? error.data : {};
if (data.code === "operation_id_conflict") {
console.error("Materialize failed: operation_id_conflict.\nReuse this operation ID with the exact original request and options, or use a new operation ID to deliberately start a new operation.");
process.exitCode = 1;
return;
}
const data = res.data as { ok?: false; code?: string; errors?: string[]; message?: string; error?: string };
if (data.code === "rig_name_running") {
console.error(data.error ?? data.message ?? "A rig with this name is already running.");
process.exitCode = 1;
@@ -221,22 +145,7 @@ export function importCommand(depsOverride?: ImportDeps): Command {
process.exitCode = 1;
return;
}
if (!confirmed || !correlated) {
console.error("Materialization is not confirmed. Inspect the operation receipt; an empty receipt can mean it is still running or failed before creation. Keep the same operation id to inspect it again.");
process.exitCode = 1;
return;
}
if (!available) {
console.error("Warning: the recorded rig is archived or resources are missing. The receipt IDs are preserved, but the topology is not currently available.");
process.exitCode = 1;
}
if (replay.success) {
console.log(`Materialization replay: ${replay.data.operation.rigId}`);
for (const id of replay.data.operation.nodeIds) console.log(` Node: ${id}`);
return;
}
if (!fresh.success) return;
const data = fresh.data;
const data = res.data as { rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> };
console.log(`Rig materialized: ${data.specName} (${data.rigId})`);
for (const n of data.nodes) {
console.log(` ${n.logicalId}: ${n.status}`);
@@ -1,237 +0,0 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import http from "node:http";
import { randomUUID } from "node:crypto";
import { importCommand, type ImportDeps } from "../src/commands/import.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
const operationId = randomUUID();
const operation = { contract: "operation-correlation.v1", operationId, rigId: "prepared-rig", nodeIds: ["prepared-node"],
current: { rig: { id: "prepared-rig", exists: true, archivedAt: null }, nodes: [{ id: "prepared-node", exists: true, latestSession: null }] } };
const replies = {
fresh: { status: 201, data: { rigId: "prepared-rig", specName: "prepared", specVersion: "0.2", nodes: [{ logicalId: "core.shell", status: "materialized" }], operation } },
replay: { status: 200, data: { replayed: true, operation } },
conflict: { status: 409, data: { code: "operation_id_conflict" } },
empty: { status: 200, data: { replayed: true, operation: { ...operation, rigId: null, nodeIds: [], current: { rig: null, nodes: [] } } } },
};
let freshOperation: unknown = operation;
let freshNodes = replies.fresh.data.nodes;
let replayOperation: unknown = operation;
let mode: keyof typeof replies = "fresh";
let supportsMaterialization = true;
let capabilityResponse: "json" | "not-found" | "unreadable" = "json";
const requests: Array<{ url: string | undefined; headers: http.IncomingHttpHeaders; body: string }> = [];
let server: http.Server, port: number;
let priorExit: typeof process.exitCode;
const spec = 'version: "0.2"\nname: prepared\npods: []\nedges: []\n';
function lifecycle(): LifecycleDeps {
return {
spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(() => true),
readFile: p => p === STATE_FILE ? JSON.stringify({ pid: 123, port, db: "fixture.sqlite", startedAt: "2026-01-01T00:00:00Z" }) : null,
writeFile: vi.fn(), removeFile: vi.fn(), exists: p => p === STATE_FILE, mkdirp: vi.fn(), openForAppend: () => 3,
isProcessAlive: () => true,
};
}
beforeAll(async () => {
server = http.createServer(async (req, res) => {
let body = "";
for await (const chunk of req) body += chunk;
requests.push({ url: req.url, headers: req.headers, body });
if (req.method === "GET" && req.url === "/api/operations/capabilities") {
if (capabilityResponse !== "json") {
res.writeHead(capabilityResponse === "not-found" ? 404 : 200, { "Content-Type": "text/plain" });
res.end(capabilityResponse === "not-found" ? "Not Found" : "incomplete response");
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ contract: "operation-correlation.v1", kinds: supportsMaterialization ? ["rig.materialize"] : ["rig.import"] }));
return;
}
const reply = mode === "fresh" ? { ...replies.fresh, data: { ...replies.fresh.data, nodes: freshNodes, operation: freshOperation } }
: mode === "replay" ? { ...replies.replay, data: { replayed: true, operation: replayOperation } } : replies[mode];
res.writeHead(reply.status, { "Content-Type": "application/json" });
res.end(JSON.stringify(reply.data));
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("fixture did not bind TCP");
port = address.port;
});
afterAll(async () => { await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())); });
afterEach(() => {
process.exitCode = priorExit; vi.restoreAllMocks(); requests.length = 0;
supportsMaterialization = true; capabilityResponse = "json"; freshOperation = operation; replayOperation = operation;
freshNodes = replies.fresh.data.nodes;
});
async function run(flags: string[]) {
priorExit = process.exitCode;
process.exitCode = undefined;
const output: string[] = [], errors: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => output.push(args.join(" ")));
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => errors.push(args.join(" ")));
const deps: ImportDeps = { lifecycleDeps: lifecycle(), clientFactory: () => new DaemonClient(`http://127.0.0.1:${port}`), readFile: () => spec };
const command = new Command().exitOverride();
command.addCommand(importCommand(deps));
await command.parseAsync(["node", "rig", "import", "team.yaml", ...flags]);
return { output, errors, exitCode: process.exitCode };
}
describe("rig import --materialize-only operation receipts", () => {
it.each(["fresh", "replay"] as const)("prints a usable %s JSON receipt and forwards the same operation id", async response => {
mode = response;
const result = await run(["--materialize-only", "--operation-id", operationId, "--json", "--rig-root", "/fixture/team"]);
expect(result.exitCode).not.toBe(1);
expect(result.errors).toEqual([]);
expect(result.output).toHaveLength(1);
expect(JSON.parse(result.output[0]!)).toEqual(replies[response].data);
expect(requests).toHaveLength(2);
expect(requests[0]?.url).toBe("/api/operations/capabilities");
expect(requests[1]).toMatchObject({ url: "/api/rigs/import/materialize", body: spec,
headers: { "x-openrig-operation-id": operationId, "x-rig-root": "/fixture/team" } });
});
it("renders a replay in text without assuming fresh-result fields exist", async () => {
mode = "replay";
const result = await run(["--materialize-only", "--operation-id", operationId]);
expect(result.exitCode).not.toBe(1);
expect(result.output.join("\n")).toContain("prepared-rig");
expect(result.output.join("\n")).toContain("prepared-node");
expect(result.errors).toEqual([]);
});
it.each([
{ response: "fresh", target: false }, { response: "replay", target: false },
{ response: "fresh", target: true }, { response: "replay", target: true },
] as const)("confirms an empty-pod $response receipt (target: $target)", async ({ response, target }) => {
mode = response;
const emptyPodOperation = { ...operation, nodeIds: [], current: { ...operation.current, nodes: [] } };
freshOperation = emptyPodOperation;
replayOperation = emptyPodOperation;
freshNodes = [];
const result = await run(["--materialize-only", "--operation-id", operationId,
...(target ? ["--target-rig", "prepared-rig"] : [])]);
expect(result.exitCode).not.toBe(1);
expect(result.errors).toEqual([]);
expect(result.output).toEqual([response === "fresh"
? "Rig materialized: prepared (prepared-rig)" : "Materialization replay: prepared-rig"]);
expect(requests[1]?.headers["x-target-rig-id"]).toBe(target ? "prepared-rig" : undefined);
});
it.each([
{ name: "archived rig", current: { ...operation.current, rig: { ...operation.current.rig, archivedAt: "2026-01-02T00:00:00Z" } } },
{ name: "deleted rig", current: { ...operation.current, rig: { ...operation.current.rig, exists: false } } },
{ name: "deleted node", current: { ...operation.current, nodes: [{ id: "prepared-node", exists: false }] } },
])("recovers recorded IDs with a warning for a $name", async ({ current }) => {
mode = "replay";
replayOperation = { ...operation, current };
const result = await run(["--materialize-only", "--operation-id", operationId]);
expect(result.exitCode).toBe(1);
expect(result.output.join("\n")).toContain("Materialization replay: prepared-rig");
expect(result.output.join("\n")).toContain("Node: prepared-node");
expect(result.errors.join("\n")).toMatch(/warning.*archived|warning.*missing/i);
expect(result.errors.join("\n")).not.toContain("not confirmed");
expect(requests).toHaveLength(2);
});
it("retains the native JSON receipt and nonzero exit for an unavailable replay", async () => {
mode = "replay";
replayOperation = { ...operation, current: { ...operation.current, nodes: [{ id: "prepared-node", exists: false }] } };
const result = await run(["--materialize-only", "--operation-id", operationId, "--json"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.output[0]!)).toEqual({ replayed: true, operation: replayOperation });
});
it.each(["conflict", "empty"] as const)("prints %s JSON but does not report successful materialization", async response => {
mode = response;
const result = await run(["--materialize-only", "--operation-id", operationId, "--json"]);
expect(result.exitCode).toBe(1);
expect(result.output).toHaveLength(1);
expect(JSON.parse(result.output[0]!)).toEqual(replies[response].data);
});
it("explains how to resolve an operation conflict in text mode", async () => {
mode = "conflict";
const result = await run(["--materialize-only", "--operation-id", operationId]);
expect(result.exitCode).toBe(1);
expect(result.output).toEqual([]);
const message = result.errors.join("\n");
expect(message).toContain("operation_id_conflict");
expect(message).toContain("exact original request and options");
expect(message).toContain("new operation ID");
expect(message).toContain("deliberately start a new operation");
expect(message).not.toContain("update your spec or target rig and retry");
});
it.each(["not-a-uuid", ""])("rejects a malformed operation id %j before any HTTP write", async id => {
mode = "fresh";
const result = await run(["--materialize-only", "--operation-id", id]);
expect(result.exitCode).toBe(1);
expect(requests).toEqual([]);
expect(result.errors.join("\n")).toMatch(/UUID/i);
});
it("rejects an empty operation id outside materialize-only", async () => {
const result = await run(["--operation-id", ""]);
expect(result.exitCode).toBe(1);
expect(requests).toEqual([]);
expect(result.errors.join("\n")).toContain("--materialize-only");
});
it("preserves ordinary materialization when the operation id is omitted", async () => {
mode = "fresh";
const result = await run(["--materialize-only"]);
expect(result.exitCode).not.toBe(1);
expect(requests.map(request => request.url)).toEqual(["/api/rigs/import/materialize"]);
expect(requests[0]?.headers["x-openrig-operation-id"]).toBeUndefined();
});
it.each(["--operation-id", "--json"])("requires materialize-only for the scoped %s option", async flag => {
const result = await run(flag === "--json" ? [flag] : [flag, operationId]);
expect(result.exitCode).toBe(1);
expect(requests).toEqual([]);
expect(result.errors.join("\n")).toContain("--materialize-only");
});
it.each(["--instantiate", "--workspace-only", "--preflight"])("rejects scoped operation options combined with %s", async flag => {
const result = await run(["--materialize-only", "--operation-id", operationId, flag]);
expect(result.exitCode).toBe(1);
expect(requests).toEqual([]);
});
it("checks native capability support before a write to an older daemon", async () => {
supportsMaterialization = false;
const result = await run(["--materialize-only", "--operation-id", operationId]);
expect(result.exitCode).toBe(1);
expect(requests.map(request => request.url)).toEqual(["/api/operations/capabilities"]);
expect(result.errors.join("\n")).toContain("Nothing was materialized");
});
it.each(["not-found", "unreadable"] as const)("explains an unreadable %s capability response before any write", async response => {
capabilityResponse = response;
const result = await run(["--materialize-only", "--operation-id", operationId]);
expect(result.exitCode).toBe(1);
expect(requests.map(request => request.url)).toEqual(["/api/operations/capabilities"]);
expect(result.output).toEqual([]);
expect(result.errors.join("\n")).toContain("Nothing was materialized");
expect(result.errors.join("\n")).toContain("upgrade the daemon");
});
it.each([
{ name: "null rig", value: { ...operation, rigId: null } },
{ name: "different rig", value: { ...operation, rigId: "another-rig" } },
{ name: "empty node ids", value: { ...operation, nodeIds: [] } },
{ name: "extra node ids", value: { ...operation, nodeIds: ["prepared-node", "another-node"] } },
{ name: "unmatched node ids", value: { ...operation, nodeIds: ["another-node"] } },
{ name: "missing current state", value: { ...operation, current: undefined } },
])("rejects a contradictory fresh receipt: $name", async ({ value }) => {
mode = "fresh";
freshOperation = value;
const result = await run(["--materialize-only", "--operation-id", operationId, "--json"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.output[0]!)).toMatchObject({ rigId: "prepared-rig" });
});
});
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.1.0",
"version": "0.7.13",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
"author": {
"name": "Mike Schwarz",
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.1.0",
"version": "0.7.13",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
"author": {
"name": "Mike Schwarz",
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.1.0",
"version": "0.7.13",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
"logo": "assets/logo.svg"
}
@@ -1,7 +1,7 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "openrig",
"version": "0.1.0",
"version": "0.7.13",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
"author": {
"name": "Mike Schwarz",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.13",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
import type Database from "better-sqlite3";
export const OPERATION_CONTRACT = "operation-correlation.v1";
export const OPERATION_KINDS = ["rig.create", "rig.import", "rig.materialize", "member.add"] as const;
export const OPERATION_KINDS = ["rig.create", "rig.import", "member.add"] as const;
type OperationKind = typeof OPERATION_KINDS[number];
type Row = { request_hash: string; rig_id: string | null; node_ids_json: string };
@@ -552,7 +552,7 @@ export class PodRigInstantiator {
async materialize(
rigSpecYaml: string,
rigRoot: string,
opts?: { targetRigId?: string; suppressSummaryEvent?: boolean; cwdOverride?: string; recordCreated?: RecordCreated },
opts?: { targetRigId?: string; suppressSummaryEvent?: boolean; cwdOverride?: string },
): Promise<MaterializeOutcome> {
let raw: unknown;
try {
@@ -698,9 +698,9 @@ export class PodRigInstantiator {
if (!materializedRigId) {
const rig = this.deps.rigRepo.createRig(rigSpec.name);
materializedRigId = rig.id;
opts?.recordCreated?.(rig.id);
persistedEvents.push(this.deps.eventBus.persistWithinTransaction({ type: "rig.created", rigId: materializedRigId }));
}
opts?.recordCreated?.(materializedRigId);
// PL-007: persist the rig's typed workspace block when declared.
if (rigSpec.workspace) {
+4 -11
View File
@@ -202,9 +202,7 @@ rigspecImportRoutes.post("/workspace", async (c) => {
// POST /api/rigs/import/materialize -> create rig topology without launching
rigspecImportRoutes.post("/materialize", async (c) => {
const header = c.req.header("X-OpenRig-Operation-Id");
if (!validOperationId(header)) return c.json({ error: "Invalid X-OpenRig-Operation-Id" }, 400);
const { podInstantiator, rigRepo } = getDeps(c);
const { podInstantiator } = getDeps(c);
const body = await c.req.text();
let raw: unknown;
@@ -225,12 +223,7 @@ rigspecImportRoutes.post("/materialize", async (c) => {
const targetRigId = c.req.header("X-Target-Rig-Id") ?? undefined;
const cwdOverride = c.req.header("X-Cwd-Override") ?? undefined;
const operation = reserveOperation(rigRepo.db, header, "rig.materialize", targetRigId ? [targetRigId] : [],
body, [rigRoot, cwdOverride ?? null]);
if (operation.state === "conflict") return c.json({ code: "operation_id_conflict" }, 409);
if (operation.state === "replay") return c.json({ replayed: true, operation: operation.operation }, 200);
const outcome = await podInstantiator.materialize(body, rigRoot, { targetRigId, cwdOverride,
...(operation.state === "fresh" ? { recordCreated: operation.recordCreated } : {}) });
const outcome = await podInstantiator.materialize(body, rigRoot, { targetRigId, cwdOverride });
if (!outcome.ok) {
const status = outcome.code === "validation_failed" ? 400
: outcome.code === "preflight_failed" ? 409
@@ -241,9 +234,9 @@ rigspecImportRoutes.post("/materialize", async (c) => {
const body = outcome.code === "rig_name_running"
? { ...outcome, error: outcome.message }
: outcome;
return c.json(operation.state === "fresh" ? { ...body, operation: operation.read() } : body, status);
return c.json(body, status);
}
return c.json(operation.state === "fresh" ? { ...outcome.result, operation: operation.read() } : outcome.result, 201);
return c.json(outcome.result, 201);
});
// POST /api/rigs/import/validate -> validate only (auto-detects format)
@@ -1,162 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { makeTopologyDestinationCheck } from "../src/domain/queue-destination-check.js";
const databases: Array<ReturnType<typeof createFullTestDb>> = [];
afterEach(() => { vi.restoreAllMocks(); for (const db of databases.splice(0)) db.close(); });
function fixture() {
const db = createFullTestDb();
databases.push(db);
const setup = createTestApp(db);
const launch = vi.spyOn(setup.nodeLauncher, "launchNode").mockImplementation(async () => {
throw new Error("materialization must not launch a session");
});
const yaml = 'version: "0.2"\nname: prepared-team\npods:\n - id: core\n label: Core\n members:\n - id: shell\n runtime: terminal\n agent_ref: "builtin:terminal"\n profile: none\n cwd: /tmp\n edges: []\nedges: []\n';
const operationId = randomUUID();
const call = (changes: { body?: string; root?: string; cwd?: string; target?: string; operation?: string } = {}) =>
setup.app.request("/api/rigs/import/materialize", { method: "POST", headers: {
"X-OpenRig-Operation-Id": changes.operation ?? operationId,
"X-Rig-Root": changes.root ?? "/tmp",
...(changes.cwd ? { "X-Cwd-Override": changes.cwd } : {}),
...(changes.target ? { "X-Target-Rig-Id": changes.target } : {}),
}, body: changes.body ?? yaml });
return { ...setup, db, launch, yaml, call, operationId };
}
describe("no-launch materialization operation correlation", () => {
const emptyPodYaml = 'version: "0.2"\nname: prepared-team\npods:\n - id: empty\n label: Empty\n members: []\n edges: []\nedges: []\n';
it.each([false, true])("records and replays a committed empty pod (existing target: %s)", async target => {
const { call, rigRepo, db, operationId, launch } = fixture();
if (target) expect((await call({ operation: randomUUID() })).status).toBe(201);
const targetRig = target ? rigRepo.findRigsByName("prepared-team")[0]!.id : undefined;
const request = { body: emptyPodYaml, target: targetRig };
const first = await call(request);
expect(first.status).toBe(201);
const result = await first.json();
const rigId = rigRepo.findRigsByName("prepared-team")[0]!.id;
if (target) expect(rigId).toBe(targetRig);
expect(result).toMatchObject({ rigId, nodes: [], operation: { operationId, rigId, nodeIds: [],
current: { rig: { id: rigId, exists: true, archivedAt: null }, nodes: [] } } });
const replay = await call(request);
expect(replay.status).toBe(200);
expect(await replay.json()).toEqual({ replayed: true, operation: result.operation });
expect(db.prepare("SELECT count(*) AS n FROM rigs").get()).toEqual({ n: 1 });
expect(db.prepare("SELECT count(*) AS n FROM pods").get()).toEqual({ n: target ? 2 : 1 });
expect(db.prepare("SELECT count(*) AS n FROM nodes").get()).toEqual({ n: target ? 1 : 0 });
expect(db.prepare("SELECT count(*) AS n FROM sessions").get()).toEqual({ n: 0 });
expect(launch).not.toHaveBeenCalled();
});
it.each([false, true])("keeps a failed empty-pod operation unconfirmed (existing target: %s)", async target => {
const { call, rigRepo, db, launch } = fixture();
if (target) expect((await call({ operation: randomUUID() })).status).toBe(201);
const targetRig = target ? rigRepo.findRigsByName("prepared-team")[0]!.id : undefined;
db.exec("CREATE TRIGGER reject_empty_pod BEFORE INSERT ON pods WHEN NEW.namespace = 'empty' BEGIN SELECT RAISE(ABORT, 'pod failed'); END");
const request = { body: emptyPodYaml, target: targetRig };
expect((await call(request)).status).toBe(500);
const replay = await call(request);
expect(replay.status).toBe(200);
expect(await replay.json()).toMatchObject({ replayed: true, operation: { rigId: null, nodeIds: [],
current: { rig: null, nodes: [] } } });
expect(db.prepare("SELECT count(*) AS n FROM rigs").get()).toEqual({ n: target ? 1 : 0 });
expect(db.prepare("SELECT count(*) AS n FROM pods").get()).toEqual({ n: target ? 1 : 0 });
expect(db.prepare("SELECT count(*) AS n FROM nodes").get()).toEqual({ n: target ? 1 : 0 });
expect(db.prepare("SELECT count(*) AS n FROM sessions").get()).toEqual({ n: 0 });
expect(launch).not.toHaveBeenCalled();
});
it("recovers the exact rig and node ids after a lost response, with real destination admission and no sessions", async () => {
const { db, rigRepo, eventBus, call, operationId, launch } = fixture();
const queue = new QueueRepository(db, eventBus, { validateRig: makeTopologyDestinationCheck({
findRigsByName: name => rigRepo.findRigsByName(name),
loadHumanRegistry: () => ({ ok: true, entities: [] }), sharedChannels: () => [],
}) });
const task = { qitemId: "qitem-prepared-team-day", sourceSession: "setup@system", destinationSession: "core-shell@prepared-team",
tier: "task" as const, summary: "Start the day", tags: ["start-of-day"], body: "Own-team daily recipe", nudge: false };
await expect(queue.create(task)).rejects.toMatchObject({ code: "unknown_destination_rig" });
// Commit, then discard the response: the next request must recover ids from native state.
expect((await call()).status).toBe(201);
const rig = rigRepo.findRigsByName("prepared-team")[0]!;
const nodeIds = rigRepo.getRig(rig.id)!.nodes.map(node => node.id);
const replay = await call();
expect(replay.status).toBe(200);
expect(await replay.json()).toMatchObject({ replayed: true, operation: {
operationId, rigId: rig.id, nodeIds, current: { rig: { id: rig.id, exists: true },
nodes: nodeIds.map(id => ({ id, exists: true, latestSession: null })) },
} });
expect(db.prepare("SELECT count(*) AS n FROM rigs").get()).toEqual({ n: 1 });
expect(db.prepare("SELECT count(*) AS n FROM nodes").get()).toEqual({ n: 1 });
expect(db.prepare("SELECT count(*) AS n FROM sessions").get()).toEqual({ n: 0 });
expect(launch).not.toHaveBeenCalled();
await expect(queue.create(task)).resolves.toMatchObject({ qitemId: task.qitemId });
const parked = queue.update({ qitemId: task.qitemId, actorSession: task.sourceSession, state: "blocked",
blockedOn: "external:new-day", wakeAfterSeconds: 86400 });
expect(parked.waiting?.nextBackstop).toMatchObject({ mechanism: expect.stringMatching(/^watchdog:/), dueAt: expect.any(String) });
});
it("returns the first request's recorded identities in its ordinary response", async () => {
const { call, rigRepo, operationId } = fixture();
const first = await call();
expect(first.status).toBe(201);
const result = await first.json();
const rig = rigRepo.findRigsByName("prepared-team")[0]!;
expect(result.operation).toMatchObject({ contract: "operation-correlation.v1", operationId, rigId: rig.id,
nodeIds: rigRepo.getRig(rig.id)!.nodes.map(node => node.id) });
expect((await (await call()).json()).operation).toEqual(result.operation);
});
it.each(["spec", "root", "cwd", "target"])("rejects changed %s under the same operation before creating topology", async changed => {
const { call, yaml, db } = fixture();
expect((await call()).status).toBe(201);
const changes = changed === "spec" ? { body: yaml + "\n" } : changed === "root" ? { root: "/different-root" }
: changed === "cwd" ? { cwd: "/different-cwd" } : { target: "another-rig" };
const conflict = await call(changes);
expect(conflict.status).toBe(409);
expect(await conflict.json()).toMatchObject({ code: "operation_id_conflict" });
expect(db.prepare("SELECT count(*) AS n FROM rigs").get()).toEqual({ n: 1 });
expect(db.prepare("SELECT count(*) AS n FROM nodes").get()).toEqual({ n: 1 });
});
it("rejects invalid operation ids before reserving or creating anything", async () => {
const { call, db } = fixture();
expect((await call({ operation: "invalid" })).status).toBe(400);
expect(db.prepare("SELECT count(*) AS n FROM operation_correlations").get()).toEqual({ n: 0 });
expect(db.prepare("SELECT count(*) AS n FROM rigs").get()).toEqual({ n: 0 });
});
it("preserves an empty receipt after failed validation without phantom topology or a second execution", async () => {
const { call, yaml, db } = fixture();
const invalid = { body: yaml.replace(" label: Core\n", "") };
expect((await call(invalid)).status).toBe(400);
const replay = await call(invalid);
expect(replay.status).toBe(200);
expect(await replay.json()).toMatchObject({ replayed: true, operation: { rigId: null, nodeIds: [] } });
expect(db.prepare("SELECT count(*) AS n FROM rigs").get()).toEqual({ n: 0 });
expect(db.prepare("SELECT count(*) AS n FROM nodes").get()).toEqual({ n: 0 });
});
it("rolls back rig and node writes when transactional correlation recording fails", async () => {
const { call, db } = fixture();
db.exec("CREATE TRIGGER reject_materialize_record BEFORE UPDATE ON operation_correlations WHEN NEW.node_ids_json <> '[]' BEGIN SELECT RAISE(ABORT, 'record failed'); END");
expect((await call()).status).toBe(500);
expect(db.prepare("SELECT count(*) AS n FROM rigs").get()).toEqual({ n: 0 });
expect(db.prepare("SELECT count(*) AS n FROM nodes").get()).toEqual({ n: 0 });
expect(db.prepare("SELECT rig_id, node_ids_json FROM operation_correlations").get()).toEqual({ rig_id: null, node_ids_json: "[]" });
});
it("keeps uncorrelated materialization backward compatible", async () => {
const { app, yaml, launch } = fixture();
const response = await app.request("/api/rigs/import/materialize", {
method: "POST", headers: { "X-Rig-Root": "/tmp" }, body: yaml,
});
expect(response.status).toBe(201);
const result = await response.json();
expect(result).toMatchObject({ specName: "prepared-team", nodes: [{ logicalId: "core.shell", status: "materialized" }] });
expect(result).not.toHaveProperty("operation");
expect(launch).not.toHaveBeenCalled();
});
});
@@ -18,11 +18,11 @@ const id = () => randomUUID();
afterEach(() => { for (const value of opened.splice(0)) value.close(); });
describe("operation correlation", () => {
it("returns a capability marker for the supported kinds", async () => {
it("returns a capability marker for the three supported kinds", async () => {
const { app } = createTestApp(db());
const response = await app.request("/api/operations/capabilities");
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ contract: "operation-correlation.v1", kinds: ["rig.create", "rig.import", "rig.materialize", "member.add"] });
expect(await response.json()).toEqual({ contract: "operation-correlation.v1", kinds: ["rig.create", "rig.import", "member.add"] });
});
it("reserves a legacy import before paused preflight so a concurrent replay has one executor", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.13",
"private": true,
"license": "Apache-2.0",
"type": "module",