From 6e38c12cfab88f671f5a2e57867b6133522035da Mon Sep 17 00:00:00 2001 From: d3cker Date: Wed, 16 Sep 2026 22:18:27 +0200 Subject: [PATCH] Keep inactive repository inventory responses JSON-safe --- CHANGELOG.md | 4 ++++ docs/advanced.md | 3 ++- src/repositories.ts | 5 ++++- test/repositories.test.ts | 4 +++- test/setup.test.ts | 8 ++++---- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01fcdc6..ee56464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ include the full version, for example `## 0.7.0-beta.1`. ### Fixed +- Keep repository inventory RPC responses valid JSON when an owner has no runtime + snapshots. Isolate setup-test registries so validation never adds fixture + repositories to the operator's inventory. + - Reconcile timed-out or interrupted sessions completed manually after a blocked task or service restart. Verify and publish through the dispatcher, then process queued issue feedback on the same branch and PR, including legacy checkpoints. diff --git a/docs/advanced.md b/docs/advanced.md index 2d339db..c283a85 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -127,7 +127,8 @@ Full task history remains available through `status`; see the `opencode2-automation list [--json]` is independent of the current checkout and service discovery. `automation.github.repositories` accepts `{}` and returns the same `{ entries, warnings }` report on the connected server. The method reads -local registry/snapshot files; it does not invoke RPC in other owner locations, +local registry/snapshot files and omits absent snapshot fields so inactive or +missing owners also produce valid JSON. It does not invoke RPC in other owner locations, which could activate their plugins. `/bot` → **Repositories** consumes this API. `init` and combined-plugin activation register standard configurations. Dispatcher diff --git a/src/repositories.ts b/src/repositories.ts index e77decb..76e17a9 100644 --- a/src/repositories.ts +++ b/src/repositories.ts @@ -109,7 +109,10 @@ export async function listRepositories(now = Date.now()): Promise Boolean(v && !v.stopped && alive(v.pid) && now >= v.at && now - v.at <= 15000); if (!d || d.stopped || !alive(d.pid)) { row.status = "not-running"; row.reason = "Configured; dispatcher is not running or has not reported since registration. Snapshots, if present, are historical."; diff --git a/test/repositories.test.ts b/test/repositories.test.ts index 88711f7..65a1183 100644 --- a/test/repositories.test.ts +++ b/test/repositories.test.ts @@ -33,7 +33,9 @@ test("inventory is read-only and distinguishes active, paused, stale, stopped an const f = await fixture(); let stopD: (() => Promise) | undefined, stopS: (() => Promise) | undefined; try { await registerRepositories([f.entry]); - assert.equal((await listRepositories()).entries[0]?.status, "not-running"); + const inactive = await listRepositories(); + assert.equal(inactive.entries[0]?.status, "not-running"); + assert.deepEqual(inactive, JSON.parse(JSON.stringify(inactive)), "RPC output must contain JSON values only, even before activation"); stopD = publishRepositoryRuntime(f.project, "dispatcher", () => f.dispatcher); stopS = publishRepositoryRuntime(f.project, "scheduler", () => f.scheduler); await waitFor(async () => (await listRepositories()).entries[0]?.status === "running"); diff --git a/test/setup.test.ts b/test/setup.test.ts index 70bedca..02b1f28 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -17,19 +17,19 @@ test("configuration command writes one field and never overwrites existing setti const mock = join(dir, "github-mock.mjs"); await writeFile(mock, 'globalThis.fetch = async url => { if (!String(url).startsWith("https://api.github.com/")) throw new Error("Unexpected network request"); return Response.json(String(url).endsWith("/user") ? {login:"alice"} : {default_branch:"main"}); };'); const args = ["--import", import.meta.resolve("tsx"), "--import", mock, fileURLToPath(new URL("../src/setup.ts", import.meta.url)), "init", "--model", "provider/model", "--yes"]; - const result = await exec(process.execPath, args, { cwd: dir, env: { ...process.env, GITHUB_TOKEN: "fixture-secret" } }); + const result = await exec(process.execPath, args, { cwd: dir, env: { ...process.env, XDG_STATE_HOME: join(dir, "state"), GITHUB_TOKEN: "fixture-secret" } }); assert.match(result.stdout, /Ready: owner\/repo/); assert.ok(!result.stdout.includes("fixture-secret")); const path = join(dir, ".opencode", "automation.json"); assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { model: "provider/model" }); await rm(path); await rm(join(dir, "package.json")); - const skipped = await exec(process.execPath, [...args, "--skip-tests"], { cwd: dir, env: { ...process.env, GITHUB_TOKEN: "fixture-secret" } }); + const skipped = await exec(process.execPath, [...args, "--skip-tests"], { cwd: dir, env: { ...process.env, XDG_STATE_HOME: join(dir, "state"), GITHUB_TOKEN: "fixture-secret" } }); assert.match(skipped.stdout, /skipped/); assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { model: "provider/model", check: false }); await writeFile(path, JSON.stringify({ model: "provider/model" })); await writeFile(join(dir, "package.json"), JSON.stringify({ scripts: { test: "node --test" } })); - await assert.rejects(exec(process.execPath, args, { cwd: dir, env: { ...process.env, GITHUB_TOKEN: "fixture-secret" } })); + await assert.rejects(exec(process.execPath, args, { cwd: dir, env: { ...process.env, XDG_STATE_HOME: join(dir, "state"), GITHUB_TOKEN: "fixture-secret" } })); assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { model: "provider/model" }); } finally { await rm(dir, { recursive: true, force: true }); } }); @@ -43,7 +43,7 @@ test("interactive CLI saves account-derived defaults and displays English prompt const mock = join(dir, "interactive-mock.mjs"); await writeFile(mock, 'Object.defineProperty(process.stdin,"isTTY",{value:true});globalThis.fetch=async url=>{if(!String(url).startsWith("https://api.github.com/"))throw new Error("Unexpected network request");return Response.json(String(url).endsWith("/user")?{login:"alice"}:{default_branch:"main"})};'); const output = await new Promise((resolve, reject) => { - const child = spawn(process.execPath, ["--import", import.meta.resolve("tsx"), "--import", mock, fileURLToPath(new URL("../src/setup.ts", import.meta.url)), "init", "--model", "provider/model", "--capabilities", "text,vision", "--base-branch", "main"], { cwd: dir, env: { ...process.env, GITHUB_TOKEN: "fixture-secret" }, stdio: ["pipe", "pipe", "pipe"] }); + const child = spawn(process.execPath, ["--import", import.meta.resolve("tsx"), "--import", mock, fileURLToPath(new URL("../src/setup.ts", import.meta.url)), "init", "--model", "provider/model", "--capabilities", "text,vision", "--base-branch", "main"], { cwd: dir, env: { ...process.env, XDG_STATE_HOME: join(dir, "state"), GITHUB_TOKEN: "fixture-secret" }, stdio: ["pipe", "pipe", "pipe"] }); let output = "", error = "", pending = ""; const timer = setTimeout(() => { child.kill(); reject(new Error("Wizard timed out")); }, 15000); child.stdout.on("data", chunk => {