diff --git a/.github/scripts/ci-paths-filter.test.mjs b/.github/scripts/ci-paths-filter.test.mjs new file mode 100644 index 00000000000..5de6dcb421e --- /dev/null +++ b/.github/scripts/ci-paths-filter.test.mjs @@ -0,0 +1,327 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, before, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { Evaluator, Lexer, Parser, data } from "@actions/expressions"; +import { parse } from "yaml"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const workflow = parse( + readFileSync(path.join(root, ".github/workflows/ci.yml"), "utf8"), +); +const filterSteps = workflow.jobs.changes.steps.filter( + (step) => step.id === "filter", +); +assert.equal(filterSteps.length, 1); +const filter = filterSteps[0]; +const actionRef = "dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d"; +const actionSha256 = + "d7c109e4a3c9f256aab1baf62473673a2e36d15efe02848220001de80067d5b2"; +const scratch = mkdtempSync(path.join(tmpdir(), "buzz-ci-paths-filter-")); +const actionPath = path.join(scratch, "paths-filter.cjs"); + +after(() => rmSync(scratch, { recursive: true, force: true })); +before(async () => { + assert.equal( + filter.uses, + actionRef, + "review and qualify action updates explicitly", + ); + assert.equal(filter.with.token, "", "exercise the production Git diff path"); + const revision = actionRef.split("@")[1]; + const response = await fetch( + `https://raw.githubusercontent.com/dorny/paths-filter/${revision}/dist/index.js`, + { signal: AbortSignal.timeout(30_000) }, + ); + assert.equal(response.status, 200, "pinned action download must succeed"); + const bytes = Buffer.from(await response.arrayBuffer()); + assert.equal(createHash("sha256").update(bytes).digest("hex"), actionSha256); + writeFileSync(actionPath, bytes); +}); + +function outputValues(file) { + const lines = readFileSync(file, "utf8").split(/\r?\n/); + const result = {}; + for (let index = 0; index < lines.length; index++) { + if (!lines[index]) continue; + const match = /^([^<]+)<<(.+)$/.exec(lines[index]); + assert.ok(match, `expected an Actions multiline output: ${lines[index]}`); + const values = []; + while (++index < lines.length && lines[index] !== match[2]) + values.push(lines[index]); + assert.ok(index < lines.length, "output delimiter must close"); + assert.ok(!(match[1] in result), "output must be written once"); + result[match[1]] = values.join("\n"); + } + return result; +} + +// Execute the unmodified action bundle, including its Git diff and matcher, +// with the production filter input. No API token or caller credentials enter +// this disposable repository, and Git cannot read the user's global config. +function runAction(change) { + const directory = mkdtempSync(path.join(scratch, "repo-")); + const gitConfig = path.join(directory, "fixture-git-config"); + const output = path.join(directory, "action-output"); + const event = path.join(directory, "event.json"); + writeFileSync(gitConfig, ""); + writeFileSync(output, ""); + const env = { + PATH: process.env.PATH, + GIT_CONFIG_GLOBAL: gitConfig, + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + GIT_AUTHOR_NAME: "Path filter fixture", + GIT_AUTHOR_EMAIL: "fixture@example.invalid", + GIT_COMMITTER_NAME: "Path filter fixture", + GIT_COMMITTER_EMAIL: "fixture@example.invalid", + }; + const git = (...args) => + execFileSync("git", ["-c", "core.hooksPath=/dev/null", ...args], { + cwd: directory, + env, + encoding: "utf8", + timeout: 10_000, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + git("-c", "init.templateDir=", "init", "--initial-branch=main"); + for (const name of ["Justfile", "README.md"]) { + writeFileSync( + path.join(directory, name), + readFileSync(path.join(root, name)), + ); + } + git("add", "Justfile", "README.md"); + git("commit", "-m", "Base fixture"); + const base = git("rev-parse", "HEAD"); + change(directory); + git( + "add", + "-A", + "--", + ".", + ":!fixture-git-config", + ":!action-output", + ":!event.json", + ); + git("commit", "-m", "Changed fixture"); + const head = git("rev-parse", "HEAD"); + writeFileSync( + event, + JSON.stringify({ pull_request: { base: { sha: base } } }), + ); + try { + execFileSync(process.execPath, [actionPath], { + cwd: directory, + env: { + ...env, + GITHUB_EVENT_NAME: "pull_request", + GITHUB_EVENT_PATH: event, + GITHUB_OUTPUT: output, + GITHUB_WORKSPACE: directory, + GITHUB_REPOSITORY: "fixture/paths", + GITHUB_REF: "refs/heads/main", + GITHUB_SHA: head, + ...Object.fromEntries( + Object.entries(filter.with).map(([name, value]) => [ + `INPUT_${name.toUpperCase().replace(/ /g, "_")}`, + String(value), + ]), + ), + }, + timeout: 20_000, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + throw new Error( + `Pinned paths-filter failed:\n${error.stdout}\n${error.stderr}`, + { cause: error }, + ); + } + return outputValues(output); +} + +const rustLanes = [ + "rust", + "rust-cross-compile-domain", + "desktop-domain", + "relay-artifacts-domain", + "postgres-domain", + "desktop-macos-domain", + "relay-domain", + "security-domain", +]; + +function selectedLanes(outputs) { + const context = { + github: { event_name: "pull_request" }, + needs: { changes: { outputs } }, + }; + // Evaluate the existing workflow expressions; this does not execute any job + // or certify a hosted check, runner environment, or downstream test result. + return [...rustLanes, "clients", "mobile-swift-domain"].filter((name) => { + const { tokens } = new Lexer(workflow.jobs[name].if).lex(); + const expression = new Parser(tokens, Object.keys(context), []).parse(); + return ( + new Evaluator( + expression, + JSON.parse(JSON.stringify(context), data.reviver), + ) + .evaluate() + .coerceString() === "true" + ); + }); +} + +const desktopLanes = [ + "desktop-domain", + "relay-artifacts-domain", + "desktop-macos-domain", + "relay-domain", +]; +const mobileLanes = ["clients", "mobile-swift-domain"]; + +for (const scenario of [ + { + name: "root Justfile edit", + paths: ["Justfile"], + filters: ["rust"], + lanes: rustLanes, + }, + { + name: "root Justfile deletion", + remove: "Justfile", + filters: ["rust"], + lanes: rustLanes, + }, + { name: "root README", paths: ["README.md"], filters: [], lanes: [] }, + { name: "documentation", paths: ["docs/guide.md"], filters: [], lanes: [] }, + { + name: "nested Justfile documentation", + paths: ["docs/Justfile"], + filters: [], + lanes: [], + }, + { + name: "desktop UI", + paths: ["desktop/src/App.tsx"], + filters: ["desktop"], + lanes: desktopLanes, + }, + { + name: "Tauri implementation", + paths: ["desktop/src-tauri/src/main.rs"], + filters: ["desktop-rust"], + lanes: ["rust", ...desktopLanes], + }, + { + name: "Tauri subtree documentation", + paths: ["desktop/src-tauri/README.md"], + filters: ["desktop-rust"], + lanes: ["rust", ...desktopLanes], + }, + { + name: "similarly named UI file outside Tauri", + paths: ["desktop/src-tauri-helper.ts"], + filters: ["desktop"], + lanes: desktopLanes, + }, + { + name: "mobile client", + paths: ["mobile/lib/app.dart"], + filters: ["mobile"], + lanes: mobileLanes, + }, + { + name: "web client", + paths: ["web/src/main.ts"], + filters: ["web"], + lanes: ["clients"], + }, + { + name: "shared JavaScript lockfile", + paths: ["pnpm-lock.yaml"], + filters: ["desktop", "web"], + lanes: [...desktopLanes, "clients"], + }, + { + name: "shared model capabilities", + paths: ["scripts/model-capabilities.json"], + filters: ["rust", "desktop"], + lanes: rustLanes, + }, + { + name: "Rust manifest", + paths: ["Cargo.toml"], + filters: ["rust"], + lanes: rustLanes, + }, + { + name: "CI workflow", + paths: [".github/workflows/ci.yml"], + filters: ["rust", "mobile"], + lanes: [...rustLanes, ...mobileLanes], + }, + { + name: "UI plus Tauri plus mobile", + paths: [ + "desktop/src/App.tsx", + "desktop/src-tauri/src/main.rs", + "mobile/lib/app.dart", + ], + filters: ["desktop", "desktop-rust", "mobile"], + lanes: ["rust", ...desktopLanes, ...mobileLanes], + }, + { + name: "documentation plus Tauri", + paths: ["docs/guide.md", "desktop/src-tauri/src/main.rs"], + filters: ["desktop-rust"], + lanes: ["rust", ...desktopLanes], + }, + { + name: "all client and Rust surfaces", + paths: [ + "Justfile", + "desktop/src/App.tsx", + "desktop/src-tauri/src/main.rs", + "web/src/main.ts", + "mobile/lib/app.dart", + ], + filters: ["rust", "desktop", "desktop-rust", "web", "mobile"], + lanes: [...rustLanes, ...mobileLanes], + }, +]) { + test(`${scenario.name} selects its intended CI lanes`, (t) => { + const outputs = runAction((directory) => { + if (scenario.remove) rmSync(path.join(directory, scenario.remove)); + for (const relative of scenario.paths ?? []) { + const file = path.join(directory, relative); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, "Changed fixture content\n"); + } + }); + for (const name of ["rust", "desktop", "desktop-rust", "web", "mobile"]) { + assert.equal( + outputs[name], + String(scenario.filters.includes(name)), + name, + ); + } + assert.deepEqual(JSON.parse(outputs.changes), scenario.filters); + const lanes = selectedLanes(outputs); + assert.deepEqual(lanes, scenario.lanes); + t.diagnostic( + JSON.stringify({ action: actionRef, actionSha256, outputs, lanes }), + ); + }); +} diff --git a/.github/scripts/docker-cache.test.mjs b/.github/scripts/docker-cache.test.mjs new file mode 100644 index 00000000000..c1e6b89dde1 --- /dev/null +++ b/.github/scripts/docker-cache.test.mjs @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { Evaluator, Lexer, Parser, data } from "@actions/expressions"; +import { parse } from "yaml"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const workflow = parse( + readFileSync(path.join(root, ".github/workflows/docker.yml"), "utf8"), +); + +// Use GitHub's parser/evaluator on the actual workflow values. This does not +// substitute a hand-written Boolean model for the policy under test. +function evaluate(expression, context) { + const { tokens } = new Lexer(expression).lex(); + const parsed = new Parser(tokens, Object.keys(context), []).parse(); + return new Evaluator( + parsed, + JSON.parse(JSON.stringify(context), data.reviver), + ) + .evaluate() + .coerceString(); +} + +function expand(value, context) { + return value + .replace(/\$\{\{([\s\S]*?)\}\}/g, (_, expression) => + evaluate(expression.trim(), context), + ) + .trim(); +} + +function step(job, id) { + const selected = job.steps.filter((candidate) => candidate.id === id); + assert.equal(selected.length, 1, `exactly one ${id} step`); + return selected[0]; +} + +function resolveCache(job, repository) { + const directory = mkdtempSync(path.join(tmpdir(), "buzz-docker-cache-")); + const output = path.join(directory, "output"); + try { + execFileSync("bash", ["-euo", "pipefail", "-c", step(job, "cache").run], { + env: { + PATH: process.env.PATH, + GITHUB_REPOSITORY: repository, + GITHUB_OUTPUT: output, + }, + }); + const lines = readFileSync(output, "utf8").trim().split("\n"); + assert.equal(lines.length, 1, "one cache ownership output"); + assert.ok(lines[0].startsWith("repository=")); + return lines[0].slice("repository=".length); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +const configurations = [ + { job: workflow.jobs.build, buildId: "build-release", suffix: "-buildcache" }, + { + job: workflow.jobs["push-gateway-build"], + buildId: "build", + suffix: "-push-gateway-buildcache", + }, +]; + +for (const { job, buildId, suffix } of configurations) { + test(`${job.name}: cache owner is this repository, independent of image destination`, () => { + for (const repository of [ + "block/buzz", + "mfethe1/buzz", + "Other-Owner/Custom.Buzz", + ]) { + const cache = resolveCache(job, repository); + assert.equal(cache, `ghcr.io/${repository.toLowerCase()}`); + for (const arch of ["amd64", "arm64"]) { + const context = { + github: { repository, event_name: "push", ref_protected: true }, + matrix: { arch }, + steps: { cache: { outputs: { repository: cache } } }, + env: { IMAGE_NAME: "ghcr.io/unrelated/release-image" }, + }; + const build = step(job, buildId); + const expected = `${cache}${suffix}:${arch}`; + assert.equal( + expand(build.with["cache-from"], context), + `type=registry,ref=${expected}`, + ); + assert.equal( + expand(build.with["cache-to"], context), + `type=registry,ref=${expected},mode=max,compression=zstd`, + ); + if (buildId === "build-release") { + assert.equal( + expand(step(job, "build-debug").with["cache-from"], context), + `type=registry,ref=${expected}`, + ); + } + } + } + }); + + test(`${job.name}: only protected push or rescue dispatch can export cache`, () => { + const cache = resolveCache(job, "mfethe1/buzz"); + for (const event of [ + "pull_request", + "pull_request_target", + "push", + "workflow_dispatch", + "schedule", + ]) { + for (const protectedRef of [true, false, undefined]) { + for (const headRepository of ["mfethe1/buzz", "untrusted/fork"]) { + const context = { + github: { + repository: "mfethe1/buzz", + event_name: event, + ref_protected: protectedRef, + event: { + pull_request: { head: { repo: { full_name: headRepository } } }, + }, + }, + matrix: { arch: "arm64" }, + steps: { cache: { outputs: { repository: cache } } }, + env: { IMAGE_NAME: "ghcr.io/block/buzz" }, + }; + const expectedWrite = + ["push", "workflow_dispatch"].includes(event) && + protectedRef === true; + assert.equal( + expand(step(job, buildId).with["cache-to"], context) !== "", + expectedWrite, + `${event}, protected=${protectedRef}, head=${headRepository}`, + ); + const login = job.steps.filter((candidate) => + candidate.uses?.startsWith("docker/login-action@"), + ); + assert.equal(login.length, 1); + assert.equal( + evaluate(login[0].if, context), + ["push", "workflow_dispatch"].includes(event) ? "true" : "false", + ); + if (event === "pull_request") { + assert.ok( + expand(step(job, buildId).with.outputs, context).endsWith( + "push=false", + ), + ); + } + } + } + } + }); +} + +test("build failures stay mandatory and source qualification remains independent", () => { + for (const { job, buildId } of configurations) { + assert.equal(job["continue-on-error"], undefined); + const build = step(job, buildId); + assert.equal(build["continue-on-error"], undefined); + assert.equal( + build.if, + undefined, + "cache policy must not skip the image build", + ); + assert.ok(!JSON.stringify(build.with).includes("ignore-error=true")); + assert.ok( + job.steps.findIndex((item) => item.id === "cache") < + job.steps.indexOf(build), + ); + assert.equal(step(job, "cache").if, undefined); + } + assert.deepEqual(workflow.jobs.merge.needs, ["build", "qualify"]); + assert.equal(workflow.jobs.qualify.if, "github.event_name != 'pull_request'"); + assert.equal(workflow.jobs.qualify.permissions.actions, "read"); + assert.equal(workflow.jobs.build.steps[0].with["persist-credentials"], false); + assert.equal( + workflow.jobs["push-gateway-build"].steps[0].with["persist-credentials"], + false, + ); +}); + +test("the production check is present in Docker path selection and the CI contract lane", () => { + assert.ok( + workflow.on.pull_request.paths.includes( + ".github/scripts/docker-cache.test.mjs", + ), + ); + const ci = parse( + readFileSync(path.join(root, ".github/workflows/ci.yml"), "utf8"), + ); + assert.ok( + ci.jobs.changes.steps.some( + (item) => item.run === "just docker-cache-check", + ), + ); + const justfile = readFileSync(path.join(root, "Justfile"), "utf8"); + assert.match(justfile, /^check:.*\bdocker-cache-check\b/m); + assert.match( + justfile, + /node --test \.github\/scripts\/docker-cache\.test\.mjs/, + ); +}); diff --git a/.github/scripts/product-dco.js b/.github/scripts/product-dco.js new file mode 100644 index 00000000000..b60a2b71d90 --- /dev/null +++ b/.github/scripts/product-dco.js @@ -0,0 +1,104 @@ +"use strict"; + +const { execFileSync } = require("node:child_process"); + +const SHA = /^[0-9a-f]{40}$/; +const MAX_COMMITS = 250; // GitHub's pull-request commits endpoint is capped here. + +function sameIdentity(actual, expected, repository, number) { + if ( + actual.number !== number || + actual.base?.repo?.full_name !== repository || + actual.base?.sha !== expected.base?.sha || + actual.head?.sha !== expected.head?.sha || + actual.base?.ref !== expected.base?.ref || + actual.state !== "open" + ) { + throw new Error("Pull request identity changed; rerun for its current base and head"); + } +} + +/** Require a Git-parsed sign-off matching the immutable commit author. */ +function hasAuthorSignoff(commit) { + const { message, author } = commit; + if ( + typeof message !== "string" || Buffer.byteLength(message) > 1024 * 1024 || + typeof author?.name !== "string" || !author.name.trim() || + typeof author?.email !== "string" || !author.email.trim() + ) { + return false; + } + const gitEnvironment = { ...process.env, GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_COUNT: "0", GIT_DIR: "/dev/null" }; + delete gitEnvironment.GIT_CONFIG_PARAMETERS; + const trailers = execFileSync("git", ["interpret-trailers", "--parse"], { + input: message, + encoding: "utf8", + timeout: 5000, + maxBuffer: 1024 * 1024, + // Repository configuration must not redefine what counts as a sign-off. + env: gitEnvironment, + }); + return trailers.split("\n").some((line) => { + const match = /^Signed-off-by:\s+([^<>\r\n]+)\s+<([^<>\s]+)>\s*$/i.exec(line); + return match && + match[1].trim().toLowerCase() === author.name.trim().toLowerCase() && + match[2].toLowerCase() === author.email.trim().toLowerCase(); + }); +} + +/** Verify every PR commit using read-only API metadata, fenced by base and head. */ +async function verify({ github, context }) { + if (context.eventName !== "pull_request_target") { + throw new Error("Product DCO requires a trusted pull_request_target event"); + } + const repository = `${context.repo.owner}/${context.repo.repo}`; + const expected = context.payload.pull_request; + const number = context.payload.number; + if ( + !Number.isSafeInteger(number) || number < 1 || + context.payload.repository?.full_name !== repository || + !SHA.test(expected?.base?.sha) || !SHA.test(expected?.head?.sha) || + !SHA.test(context.evaluatorSha) || context.evaluatorSha !== expected.base.sha + ) { + throw new Error("Missing exact repository, PR, base, head, or trusted evaluator SHA"); + } + const request = { ...context.repo, pull_number: number }; + const { data: pull } = await github.rest.pulls.get(request); + sameIdentity(pull, expected, repository, number); + if (!Number.isInteger(pull.commits) || pull.commits < 1 || pull.commits > MAX_COMMITS) { + throw new Error("Cannot prove the complete commit list: PR must contain 1–250 commits"); + } + const commits = await github.paginate(github.rest.pulls.listCommits, { + ...request, per_page: 100, + }); + if ( + commits.length !== pull.commits || + new Set(commits.map((item) => item.sha)).size !== pull.commits || + commits.some((item) => !SHA.test(item.sha)) || + commits.at(-1)?.sha !== expected.head.sha + ) { + throw new Error("Incomplete, duplicate, or stale pull-request commit list"); + } + const failures = commits.filter((item) => !hasAuthorSignoff(item.commit)).map((item) => item.sha); + // A force-push or base update while paginating must invalidate this result. + const { data: latest } = await github.rest.pulls.get(request); + sameIdentity(latest, expected, repository, number); + if (latest.commits !== pull.commits) { + throw new Error("Pull request commit count changed during verification"); + } + return { + schema_version: 1, + kind: "dco_qualification", + repository, + pull_request: number, + base_sha: expected.base.sha, + head_sha: expected.head.sha, + evaluator_sha: context.evaluatorSha, + commits: commits.map((item) => item.sha), + qualified: failures.length === 0, + missing_author_signoff: failures, + }; +} + +module.exports = { hasAuthorSignoff, verify }; diff --git a/.github/scripts/product-dco.test.js b/.github/scripts/product-dco.test.js new file mode 100644 index 00000000000..e289e7aff77 --- /dev/null +++ b/.github/scripts/product-dco.test.js @@ -0,0 +1,160 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { hasAuthorSignoff, verify } = require("./product-dco.js"); + +const BASE = "a".repeat(40); +const HEAD = "b".repeat(40); +const TESTED = "c".repeat(40); +const author = { name: "Test Author", email: "author@example.com" }; +const signed = "Example change\n\nSigned-off-by: Test Author \n"; + +function harness(count = 1) { + const context = { + repo: { owner: "mfethe1", repo: "buzz" }, + sha: BASE, + evaluatorSha: BASE, + eventName: "pull_request_target", + payload: { + repository: { full_name: "mfethe1/buzz" }, + number: 19, + pull_request: { + number: 19, state: "open", commits: count, + base: { sha: BASE, ref: "product/main", repo: { full_name: "mfethe1/buzz" } }, + head: { sha: HEAD }, + }, + }, + }; + const initial = structuredClone(context.payload.pull_request); + const latest = structuredClone(initial); + const commits = Array.from({ length: count }, (_, index) => ({ + sha: index === count - 1 ? HEAD : (index + 1).toString(16).padStart(40, "0"), + commit: { author, message: signed }, + })); + let reads = 0; + const calls = []; + const listCommits = () => { throw new Error("Must paginate listCommits"); }; + const github = { + rest: { pulls: { + get: async (request) => { + calls.push(request); + return { data: reads++ === 0 ? initial : latest }; + }, + listCommits, + } }, + paginate: async (method, request) => { + assert.equal(method, listCommits); + assert.equal(request.per_page, 100); + calls.push(request); + return commits; + }, + }; + return { github, context, initial, latest, commits, calls }; +} + +test("Git parses a real matching author sign-off with additional trailers", () => { + assert.ok(hasAuthorSignoff({ author, message: `${signed}Reviewed-by: Another Person \n` })); + assert.ok(hasAuthorSignoff({ author, message: signed.replace("Test Author", "test author") })); +}); + +test("body text, non-author sign-offs, malformed emails, and missing metadata fail", () => { + for (const message of [ + "Example change", signed.replace("author@example.com", "other@example.com"), + signed.replace("Test Author", "Different Author"), + signed.replace("", "author@example.com"), + `${signed}\nThat was a quoted example, not my sign-off.`, + "> Signed-off-by: Test Author \n", + ]) { + assert.equal(Boolean(hasAuthorSignoff({ author, message })), false, message); + } + assert.equal(hasAuthorSignoff({ author: {}, message: signed }), false); + assert.equal(hasAuthorSignoff({ author, message: "x".repeat(1024 * 1024 + 1) }), false); +}); + +test("complete paginated verification binds repo, PR, base, head, and trusted evaluator", async () => { + const data = harness(101); + const receipt = await verify(data); + assert.equal(receipt.qualified, true); + assert.equal(receipt.commits.length, 101); + assert.equal(receipt.repository, "mfethe1/buzz"); + assert.equal(receipt.pull_request, 19); + assert.equal(receipt.base_sha, BASE); + assert.equal(receipt.head_sha, HEAD); + assert.equal(receipt.evaluator_sha, BASE); + assert.equal(data.calls.length, 3); + assert.ok(data.calls.every((call) => call.owner === "mfethe1" && call.repo === "buzz" && call.pull_number === 19)); +}); + +test("one unsigned middle commit denies the whole PR, including bots and merges", async () => { + const data = harness(101); + data.commits[50].commit.message = "Unsigned change"; + data.commits[50].author = { type: "Bot" }; + data.commits[50].parents = [{ sha: BASE }, { sha: TESTED }]; + const receipt = await verify(data); + assert.equal(receipt.qualified, false); + assert.deepEqual(receipt.missing_author_signoff, [data.commits[50].sha]); +}); + +test("a caller cannot authorize the wrong repository or an unpinned commit", async () => { + for (const mutate of [ + (data) => { data.context.payload.repository.full_name = "other/repo"; }, + (data) => { data.context.eventName = "workflow_dispatch"; }, + (data) => { data.context.evaluatorSha = "product/main"; }, + (data) => { data.context.payload.pull_request.head.sha = "main"; }, + ]) { + const data = harness(); mutate(data); + await assert.rejects(verify(data)); + assert.equal(data.calls.length, 0); + } +}); + +test("stale head, base, target branch, repository or closed PR deny before listing commits", async () => { + for (const mutate of [ + (pull) => { pull.head.sha = TESTED; }, + (pull) => { pull.base.sha = TESTED; }, + (pull) => { pull.base.ref = "other-branch"; }, + (pull) => { pull.base.repo.full_name = "other/repo"; }, + (pull) => { pull.state = "closed"; }, + (pull) => { pull.number = 20; }, + ]) { + const data = harness(); mutate(data.initial); + await assert.rejects(verify(data), /identity changed/); + assert.equal(data.calls.length, 1); + } +}); + +test("head or base movement during pagination invalidates previously valid sign-offs", async () => { + for (const side of ["head", "base"]) { + const data = harness(); data.latest[side].sha = TESTED; + await assert.rejects(verify(data), /identity changed/); + } + const data = harness(); data.latest.commits += 1; + await assert.rejects(verify(data), /count changed/); +}); + +test("partial lists, duplicate entries, wrong last commit, and malformed SHAs fail closed", async () => { + for (const mutate of [ + (data) => { data.commits.splice(1, 1); }, + (data) => { data.commits[0].sha = data.commits[1].sha; }, + (data) => { data.commits.at(-1).sha = TESTED; }, + (data) => { data.commits[0].sha = "main"; }, + ]) { + const data = harness(3); mutate(data); + await assert.rejects(verify(data), /Incomplete, duplicate, or stale/); + } +}); + +test("API truncation limit and missing count do not become a partial pass", async () => { + for (const count of [0, 251, undefined, "1"]) { + const data = harness(); data.initial.commits = count; + await assert.rejects(verify(data), /complete commit list/); + assert.equal(data.calls.length, 1); + } +}); + +test("provider errors propagate rather than authorize an empty result", async () => { + const data = harness(); + data.github.paginate = async () => { throw new Error("API unavailable"); }; + await assert.rejects(verify(data), /API unavailable/); +}); diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index af5861e5f13..5ad7917b9eb 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -82,7 +82,10 @@ jobs: key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }} restore-keys: ${{ runner.os }}-hermit-cache- - name: Prime Flutter SDK - run: flutter --version + run: | + flutter --version + flutter --version --machine > "$RUNNER_TEMP/mobile-flutter-version.json" + printf '%s\n' "$GITHUB_SHA" > "$RUNNER_TEMP/mobile-tested-sha.txt" - name: Save Hermit package cache if: always() && steps.hermit-cache.outputs.cache-hit != 'true' uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 @@ -112,6 +115,17 @@ jobs: run: cd mobile && flutter analyze - name: Test run: cd mobile && flutter test + - name: Upload mobile failure evidence + if: ${{ failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mobile-failures-${{ github.run_id }}-${{ github.run_attempt }} + path: | + mobile/test/**/failures/*.png + ${{ runner.temp }}/mobile-flutter-version.json + ${{ runner.temp }}/mobile-tested-sha.txt + if-no-files-found: error + retention-days: 7 - name: Build Android debug APK run: just mobile-build-android diff --git a/.github/workflows/_ci-desktop.yml b/.github/workflows/_ci-desktop.yml index 667b4841418..15893a1cb4e 100644 --- a/.github/workflows/_ci-desktop.yml +++ b/.github/workflows/_ci-desktop.yml @@ -24,7 +24,10 @@ jobs: desktop-core: name: Desktop Core runs-on: ubuntu-latest - timeout-minutes: 45 + # The compiled-flag verification step rebuilds the workspace for each + # BUZZ_BUILD_* state and runs the full suite under all three compile + # states; the complete recipe needs ~46m, so budget 60m. + timeout-minutes: 60 if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust permissions: contents: read @@ -92,6 +95,10 @@ jobs: run: just desktop-tauri-test env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Desktop Mesh feature tests + run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --features mesh-llm --lib + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" - name: Desktop Tauri compiled-flag verification run: just desktop-tauri-test-compiled-flags env: diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index 97d32f912c7..92ce75a9514 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -35,7 +35,9 @@ jobs: desktop-e2e-relay: name: Desktop E2E Relay runs-on: ubuntu-latest - timeout-minutes: 30 + # Budget the relay and both test archives on a cache miss. Different feature + # sets can trigger additional compilation for each archive. + timeout-minutes: 60 if: inputs.lane == 'artifacts' && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust) permissions: contents: read @@ -167,6 +169,7 @@ jobs: PGUSER: buzz PG_BIN_DIR: /usr/bin REDIS_URL: redis://localhost:6379 + BUZZ_TEST_REDIS_URL: redis://localhost:6379 PGSCHEMA_PLAN_HOST: localhost PGSCHEMA_PLAN_PORT: "5432" PGSCHEMA_PLAN_USER: buzz diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a663ef957c5..6f4f2615e4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: id: filter with: token: '' + predicate-quantifier: some-with-excludes filters: | rust: - 'crates/**' @@ -58,7 +59,7 @@ jobs: - 'scripts/run-tests.sh' - 'scripts/model-capabilities.json' - 'scripts/normative-corpus.json' - - 'justfile' + - 'Justfile' desktop: - 'scripts/model-capabilities.json' - 'scripts/normative-corpus.json' @@ -92,6 +93,12 @@ jobs: run: scripts/test-release-ref-contract.sh - name: Relay image eligibility contract run: scripts/test-relay-image-eligibility-workflow.sh + - name: Docker cache ownership contract + run: just docker-cache-check + - name: CI path selection contract + run: | + pnpm install --filter buzz-workspace --frozen-lockfile --ignore-scripts + node --test .github/scripts/ci-paths-filter.test.mjs - name: Desktop release candidate contract run: scripts/test-desktop-release-candidate.sh - name: OSS desktop promotion contract @@ -114,6 +121,10 @@ jobs: scripts/test-rust-cache-contract-regressions.sh - name: CI required-context isolation contract run: scripts/test-ci-required-context-isolation.sh + - name: Product DCO regressions + run: node --test .github/scripts/product-dco.test.js + - name: Product qualification regressions + run: python3 scripts/test-product-qualification.py - name: File size policy run: just file-size-check diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f48f1bc92fb..3e350aeb15b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -21,7 +21,7 @@ name: Docker image # - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major} # + matching :debug-* tags # (+ :latest/:debug-latest for stable releases) -# - pull_request → build only (no push), cache stays warm +# - pull_request → build only, read-only cache access # - workflow_dispatch → manual relay-tag rescue at the tag itself # # Why workflow_dispatch carries a version input: @@ -49,6 +49,7 @@ on: - "Dockerfile.push-gateway" - ".dockerignore" - ".github/workflows/docker.yml" + - ".github/scripts/docker-cache.test.mjs" - "deploy/charts/buzz/Chart.yaml" - "scripts/create-deployment-eligibility-predicate.jq" - "scripts/select-qualified-ci-run.jq" @@ -129,14 +130,20 @@ jobs: max-parallelism = 2 - name: Log in to GHCR - # Skip on pull_request from forks — no GHCR creds, build-only. - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + # Skip registry login on pull requests; PR builds only read cache. + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Resolve repository cache + id: cache + run: | + repository=$(printf '%s' "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]') + printf 'repository=ghcr.io/%s\n' "$repository" >> "$GITHUB_OUTPUT" + - name: Extract metadata id: meta uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 @@ -185,9 +192,11 @@ jobs: # matrix possible. outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: | - type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} + type=registry,ref=${{ steps.cache.outputs.repository }}-buildcache:${{ matrix.arch }} + # Cache ownership follows this repository, never the release image + # override. Only protected non-PR refs may write a shared build cache. cache-to: | - ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} + ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref_protected && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', steps.cache.outputs.repository, matrix.arch) || '' }} - name: Build and push debug image by digest id: build-debug @@ -204,7 +213,7 @@ jobs: BUZZ_BUILD_URL=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: | - type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} + type=registry,ref=${{ steps.cache.outputs.repository }}-buildcache:${{ matrix.arch }} - name: Export release and debug digests if: github.event_name != 'pull_request' @@ -529,12 +538,17 @@ jobs: [worker.oci] max-parallelism = 2 - name: Log in to GHCR - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Resolve repository cache + id: cache + run: | + repository=$(printf '%s' "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]') + printf 'repository=ghcr.io/%s\n' "$repository" >> "$GITHUB_OUTPUT" - name: Extract metadata id: meta uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 @@ -553,8 +567,8 @@ jobs: platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} - cache-from: type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:${{ matrix.arch }} - cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }} + cache-from: type=registry,ref=${{ steps.cache.outputs.repository }}-push-gateway-buildcache:${{ matrix.arch }} + cache-to: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref_protected && format('type=registry,ref={0}-push-gateway-buildcache:{1},mode=max,compression=zstd', steps.cache.outputs.repository, matrix.arch) || '' }} - name: Export digest if: github.event_name != 'pull_request' env: diff --git a/Cargo.lock b/Cargo.lock index 22f69168b7a..9e3544af3b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -799,7 +799,7 @@ version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ - "darling 0.23.0", + "darling 0.20.11", "ident_case", "prettyplease", "proc-macro2", @@ -830,7 +830,6 @@ name = "buzz-acp" version = "0.1.0" dependencies = [ "anyhow", - "async-trait", "base64 0.22.1", "buzz-core", "buzz-persona", @@ -844,12 +843,10 @@ dependencies = [ "nix 0.31.3", "nostr 0.44.7", "reqwest 0.13.4", - "rusqlite", "rustls", "serde", "serde_json", "sha2 0.11.0", - "tempfile", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", @@ -1048,8 +1045,8 @@ version = "0.1.0" dependencies = [ "metrics", "metrics-util", - "opentelemetry 0.32.0", - "opentelemetry_sdk 0.32.1", + "opentelemetry", + "opentelemetry_sdk", "proc-macro2", "quote", "syn 2.0.117", @@ -1302,9 +1299,9 @@ dependencies = [ "metrics-util", "moka", "nostr 0.44.7", - "opentelemetry 0.32.0", - "opentelemetry-otlp 0.32.0", - "opentelemetry_sdk 0.32.1", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", "postcard", "pulldown-cmark", "rand 0.10.1", @@ -1364,7 +1361,6 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "url", "uuid", ] @@ -1468,6 +1464,7 @@ dependencies = [ "tokio-tungstenite 0.29.0", "tracing", "url", + "uuid", ] [[package]] @@ -1581,9 +1578,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -4052,9 +4049,9 @@ dependencies = [ [[package]] name = "iroh" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fca9b4b462c343ff88fc0af4096c186f939b602a0bc08723536ef2c31c93971" +checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72" dependencies = [ "backon", "blake3", @@ -4103,9 +4100,9 @@ dependencies = [ [[package]] name = "iroh-base" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830a582cd54410dc1aa71d4786a82c3297d7b0165accd8b6dbbb3b240b48140d" +checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" dependencies = [ "curve25519-dalek 5.0.0-rc.0", "data-encoding", @@ -4122,9 +4119,9 @@ dependencies = [ [[package]] name = "iroh-dns" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a" +checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349" dependencies = [ "arc-swap", "cfg_aliases", @@ -4173,9 +4170,9 @@ dependencies = [ [[package]] name = "iroh-relay" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354" +checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193" dependencies = [ "blake3", "bytes", @@ -4212,7 +4209,6 @@ dependencies = [ "tokio-websockets", "tracing", "url", - "vergen-gitcl", "webpki-roots 1.0.7", "ws_stream_wasm", ] @@ -4334,16 +4330,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "json5" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c" -dependencies = [ - "serde", - "ucd-trie", -] - [[package]] name = "jsonpath-rust" version = "0.7.5" @@ -4823,8 +4809,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "hex", "mesh-llm-client", @@ -4833,8 +4819,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4844,21 +4830,18 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" [[package]] name = "mesh-llm-client" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", "bytes", - "crypto_box", - "ed25519-dalek", - "hex", "httparse", "iroh", "mesh-llm-identity", @@ -4868,11 +4851,9 @@ dependencies = [ "model-artifact", "nostr-sdk", "prost 0.14.3", - "rand 0.10.1", "rustls", "serde", "serde_json", - "sha2 0.10.9", "thiserror 2.0.18", "tokio", "tracing", @@ -4881,8 +4862,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "dirs", @@ -4897,8 +4878,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -4907,20 +4888,25 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", + "chrono", "clap", "crossterm 0.28.1", + "libc", "ratatui", + "serde", "serde_json", + "tracing", + "uuid", ] [[package]] name = "mesh-llm-gpu-bench" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "serde", "serde_json", @@ -4929,8 +4915,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "serde", "serde_json", @@ -4938,8 +4924,8 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "mesh-llm-native-runtime", ] @@ -4975,8 +4961,8 @@ dependencies = [ [[package]] name = "mesh-llm-host-runtime" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "argon2", @@ -4998,7 +4984,6 @@ dependencies = [ "http-body-util", "httparse", "iroh", - "json5", "keyring", "libc", "mdns-sd", @@ -5010,6 +4995,7 @@ dependencies = [ "mesh-llm-guardrails", "mesh-llm-hf-hub", "mesh-llm-identity", + "mesh-llm-log-store", "mesh-llm-native-runtime", "mesh-llm-node", "mesh-llm-plugin", @@ -5029,9 +5015,9 @@ dependencies = [ "model-resolver", "nostr-sdk", "openai-frontend", - "opentelemetry 0.31.0", - "opentelemetry-otlp 0.31.1", - "opentelemetry_sdk 0.31.0", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", "prost 0.14.3", "rand 0.10.1", "regex-lite", @@ -5043,7 +5029,6 @@ dependencies = [ "semver", "serde", "serde_json", - "serde_yaml", "sha2 0.10.9", "skippy-coordinator", "skippy-ffi", @@ -5063,14 +5048,16 @@ dependencies = [ "tracing-subscriber", "url", "urlencoding", + "uuid", + "windows-sys 0.61.2", "zeroize", "zip", ] [[package]] name = "mesh-llm-identity" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "argon2", "base64 0.22.1", @@ -5089,10 +5076,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "mesh-llm-log-store" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +dependencies = [ + "chrono", + "data-encoding", + "hex", + "mesh-llm-events", + "rusqlite", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "uuid", + "windows-sys 0.61.2", +] + [[package]] name = "mesh-llm-native-runtime" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "serde", @@ -5102,8 +5107,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "mesh-llm-types", @@ -5116,8 +5121,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "async-trait", @@ -5133,8 +5138,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "dirs", @@ -5152,8 +5157,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "hex", @@ -5165,8 +5170,8 @@ dependencies = [ [[package]] name = "mesh-llm-release-footer" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "hex", "sha2 0.10.9", @@ -5174,16 +5179,19 @@ dependencies = [ [[package]] name = "mesh-llm-routing" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ + "blake3", "iroh", + "serde", + "serde_json", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "dirs", @@ -5205,8 +5213,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5220,8 +5228,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "dirs", @@ -5231,8 +5239,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "chrono", @@ -5258,8 +5266,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "hex", "serde", @@ -5269,13 +5277,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" [[package]] name = "mesh-mixture-of-agents" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5288,18 +5296,19 @@ dependencies = [ [[package]] name = "mesh-native-serving-plugin-api" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" [[package]] name = "mesh-native-serving-plugin-host" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "libloading", "mesh-native-serving-plugin-api", "skippy-server", + "skippy-tokenizer", ] [[package]] @@ -5439,8 +5448,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "async-trait", @@ -5450,26 +5459,29 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "async-trait", "chrono", "dirs", + "libc", "mesh-llm-hf-hub", "model-artifact", "model-ref", + "rustls", "serde", "serde_json", "sha2 0.10.9", "tokio", + "tracing", ] [[package]] name = "model-package" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "bytes", @@ -5488,16 +5500,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "model-artifact", @@ -5848,9 +5860,9 @@ dependencies = [ [[package]] name = "noq" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" +checksum = "09e4bb6601fa543c110d8957813267d5a8d775a0f8fbaccf1f615d06ba9b10da" dependencies = [ "bytes", "cfg_aliases", @@ -5870,9 +5882,9 @@ dependencies = [ [[package]] name = "noq-proto" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" +checksum = "baa7b5ccd819a9c68a0d955e67a881032d09b1a17219b1f90b0997a0888e1a15" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -5898,9 +5910,9 @@ dependencies = [ [[package]] name = "noq-udp" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" +checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" dependencies = [ "cfg_aliases", "libc", @@ -6298,19 +6310,20 @@ checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" [[package]] name = "openai-frontend" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "async-trait", "axum", "futures-core", "futures-util", + "mesh-llm-events", "mesh-llm-guardrails", "serde", "serde_json", "tokio", - "tokio-stream", "tracing", + "uuid", ] [[package]] @@ -6366,20 +6379,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "opentelemetry" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" -dependencies = [ - "futures-core", - "futures-sink", - "js-sys", - "pin-project-lite", - "thiserror 2.0.18", - "tracing", -] - [[package]] name = "opentelemetry" version = "0.32.0" @@ -6396,31 +6395,15 @@ dependencies = [ [[package]] name = "opentelemetry-http" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" dependencies = [ "async-trait", "bytes", "http", - "opentelemetry 0.31.0", - "reqwest 0.12.28", -] - -[[package]] -name = "opentelemetry-otlp" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" -dependencies = [ - "http", - "opentelemetry 0.31.0", - "opentelemetry-http", - "opentelemetry-proto 0.31.0", - "opentelemetry_sdk 0.31.0", - "prost 0.14.3", - "reqwest 0.12.28", - "thiserror 2.0.18", + "opentelemetry", + "reqwest 0.13.4", ] [[package]] @@ -6430,10 +6413,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ "http", - "opentelemetry 0.32.0", - "opentelemetry-proto 0.32.0", - "opentelemetry_sdk 0.32.1", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", "prost 0.14.3", + "reqwest 0.13.4", "thiserror 2.0.18", "tokio", "tonic", @@ -6442,49 +6427,20 @@ dependencies = [ [[package]] name = "opentelemetry-proto" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ "base64 0.22.1", "const-hex", - "opentelemetry 0.31.0", - "opentelemetry_sdk 0.31.0", + "opentelemetry", + "opentelemetry_sdk", "prost 0.14.3", "serde", - "serde_json", - "tonic", - "tonic-prost", -] - -[[package]] -name = "opentelemetry-proto" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" -dependencies = [ - "opentelemetry 0.32.0", - "opentelemetry_sdk 0.32.1", - "prost 0.14.3", "tonic", "tonic-prost", ] -[[package]] -name = "opentelemetry_sdk" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" -dependencies = [ - "futures-channel", - "futures-executor", - "futures-util", - "opentelemetry 0.31.0", - "percent-encoding", - "rand 0.9.4", - "thiserror 2.0.18", -] - [[package]] name = "opentelemetry_sdk" version = "0.32.1" @@ -6494,7 +6450,7 @@ dependencies = [ "futures-channel", "futures-executor", "futures-util", - "opentelemetry 0.32.0", + "opentelemetry", "percent-encoding", "portable-atomic", "rand 0.9.4", @@ -7866,7 +7822,6 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", - "futures-channel", "futures-core", "futures-util", "h2", @@ -7914,6 +7869,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -8032,12 +7988,23 @@ dependencies = [ [[package]] name = "rpassword" -version = "5.0.1" +version = "7.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc936cf8a7ea60c58f030fd36a612a48f440610214dc54bc36431f9ea0c3efb" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" dependencies = [ "libc", - "winapi", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", ] [[package]] @@ -8842,8 +8809,8 @@ checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" [[package]] name = "skippy-cache" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "blake3", @@ -8852,29 +8819,29 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "libloading", ] [[package]] name = "skippy-metrics" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" [[package]] name = "skippy-protocol" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "prost 0.14.3", "prost-build 0.14.3", @@ -8885,8 +8852,8 @@ dependencies = [ [[package]] name = "skippy-runtime" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "libc", @@ -8897,10 +8864,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "skippy-scheduler" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +dependencies = [ + "skippy-runtime", + "thiserror 2.0.18", +] + [[package]] name = "skippy-server" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "ahash", "anyhow", @@ -8911,10 +8887,11 @@ dependencies = [ "clap", "futures-util", "libc", + "mesh-llm-events", "mesh-native-serving-plugin-api", "model-artifact", "openai-frontend", - "opentelemetry-proto 0.31.0", + "opentelemetry-proto", "serde", "serde_json", "sha2 0.10.9", @@ -8922,25 +8899,27 @@ dependencies = [ "skippy-metrics", "skippy-protocol", "skippy-runtime", + "skippy-scheduler", "skippy-tokenizer", + "skippy-topology", "socket2", "tokio", - "tokio-stream", "tonic", + "uuid", ] [[package]] name = "skippy-tokenizer" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "serde", ] [[package]] name = "skippy-topology" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "serde", "serde_json", @@ -10275,7 +10254,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26" dependencies = [ "js-sys", - "opentelemetry 0.32.0", + "opentelemetry", "smallvec", "tracing", "tracing-core", @@ -10604,43 +10583,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "vergen" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", - "vergen-lib", -] - -[[package]] -name = "vergen-gitcl" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", - "time", - "vergen", - "vergen-lib", -] - -[[package]] -name = "vergen-lib" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", -] - [[package]] name = "version_check" version = "0.9.5" diff --git a/Justfile b/Justfile index 23bb5692a36..81aa258f677 100644 --- a/Justfile +++ b/Justfile @@ -93,7 +93,12 @@ build-release: cargo build --workspace --release # Run repo lint, formatting, and repository policy checks -check: fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy web-check mobile-check security-review-check file-size-check +check: fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy web-check mobile-check security-review-check docker-cache-check file-size-check + +# Evaluate the production Docker workflow's registry cache expressions. +docker-cache-check: + pnpm install --filter buzz-workspace --frozen-lockfile --ignore-scripts + node --test .github/scripts/docker-cache.test.mjs # Validate the trusted security-review workflow support and renderer contract. security-review-check: @@ -252,8 +257,13 @@ _ensure-migrations: _ensure-services ./scripts/seed-local-community.sh # Run clippy on the desktop Tauri Rust crate +# Features are additive, so a single invocation lints only one cfg graph. +# Both graphs ship (release-windows builds without mesh-llm), so lint both: +# the default graph covers the `#[cfg(not(feature = "mesh-llm"))]` arms and +# the feature-enabled graph covers the mesh code. desktop-tauri-clippy: _ensure-sidecar-stubs cargo clippy --manifest-path {{desktop_tauri_manifest}} --workspace --all-targets -- -D warnings + cargo clippy --manifest-path {{desktop_tauri_manifest}} --workspace --all-targets --features mesh-llm -- -D warnings # Check the desktop Tauri Rust crate compiles desktop-tauri-check: _ensure-sidecar-stubs @@ -388,6 +398,7 @@ test-unit: #!/usr/bin/env bash set -euo pipefail ./scripts/test-ensure-local-relay-key.sh + python3 -W error::ResourceWarning -m unittest discover -s scripts/fleet -p 'test_*.py' -v if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib # buzz-auth NIP-FI verifier doctests. The sealed-authority @@ -410,6 +421,11 @@ test-unit: # #[ignore]d, so --lib runs only the infra-free set. Without this gate a # stray file in migrations/ or a broken lint ships green. cargo nextest run -p buzz-db --lib + # Workflow definition/executor rules and approval read serialization. + # PostgreSQL workflow cases stay in the separate ignored-test profile. + cargo nextest run -p buzz-workflow --lib + cargo nextest run -p buzz-relay --lib \ + -E 'test(/^api::workflows::tests::/)' # Multi-tenant conformance gate (buzz-conformance): the independent # replay checker + golden fixtures. No infra — pure in-process trace # replay — so it belongs in the unit job. Run all targets (lib + the @@ -432,12 +448,6 @@ test-unit: # `cargo test --workspace`; without this step a manifest edit that # diverges Rust from the corpus ships green. cargo nextest run -p buzz-agent --lib - # buzz-acp: the ACP harness. Its ~760 --lib tests are pure in-process - # unit tests whose fixtures spawn a local POSIX shell as a fake agent — - # no relay, no database, no network. Enumerated for the same reason as - # the crates above: nothing in CI runs `cargo test --workspace`, so - # until this line existed the harness that dispatches every agent turn - # had zero executed test coverage in CI on any platform. # buzz-agent: two infra-free concerns run together by executing the # whole crate (lib + integration tests), because nothing in CI runs # `cargo test --workspace`, so without this stanza neither its @@ -491,9 +501,14 @@ test-unit: # unit job either. cargo nextest run -p buzz-relay --lib \ -E '(test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/)' - # ACP author-gate and queue tests protect the trust boundary between - # relay events and agent prompts. They are infra-free; ignored lifecycle - # tests remain excluded and run in their dedicated integration lanes. cargo nextest run -p buzz-acp --lib + # Real localhost HTTP tests for the startup storage admission deadline. + # Keep them in the infra-free gate; the broader Git suite uses MinIO. + cargo nextest run -p buzz-relay --lib \ + -E 'test(/^api::git::store::probe_deadline::tests::/)' + # Task notification/privacy and reconnect controls are infra-free here; + # the ignored signed HTTP/WebSocket flow runs in the PostgreSQL profile. + cargo nextest run -p buzz-relay -p buzz-ws-client --lib \ + -E '(package(buzz-relay) and (test(/^api::tasks::tests::/) + test(/^state::task_invalidation::tests::/) + test(/^protocol::tests::/))) or package(buzz-ws-client)' else ./scripts/run-tests.sh unit fi @@ -725,7 +740,11 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs fi trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true' EXIT echo "Starting standalone desktop on Vite port ${BUZZ_VITE_PORT}; no relay services were started" - pnpm exec tauri dev --config "$BUZZ_TAURI_CONFIG" {{ARGS}} + FEATURES=() + if [[ -n "{{mesh}}" ]]; then + FEATURES=(--features mesh-llm) + fi + pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}} # Run the desktop app against the internal staging relay (installs deps + builds agent tools automatically) staging *ARGS: bootstrap _ensure-sidecar-stubs @@ -870,7 +889,7 @@ mobile-test: mobile-emoji-data: node {{mobile_dir}}/scripts/generate-emoji-data.mjs -# Compile an unsigned Android debug APK (worktree-aware debug identity) +# Compile an Android debug APK signed with the debug key (worktree-aware identity) mobile-build-android: ./scripts/mobile-worktree-overrides.sh unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter build apk --debug --no-pub diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 41d9a214bdd..3d011eb8ebb 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -270,6 +270,10 @@ Forum event kinds: 4. **Prompting** — When events are pending and no prompt is in flight for that channel, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`. 5. **Agent response** — The agent processes the prompt and uses the Buzz CLI (`send_message`, `get_messages`, etc.) to interact with Buzz. 6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events. + If the inbound queue overflows, the harness attempts replay for affected + subscriptions when capacity and relay quota permit, with at least five seconds + between attempts. Recovery depends on available relay history and the consumer + making progress; complete delivery is not guaranteed. Each channel has at most one prompt in flight. Multiple channels can be processed concurrently when agents > 1. diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 6467fbc8f3f..cce2d779c1e 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -3095,6 +3095,19 @@ mod tests { .expect("failed to spawn test script") } + /// Reap a response fixture after its last wire interaction. + async fn shutdown_fixture(client: &mut AcpClient) { + client.shutdown().await; + assert!( + client + .child + .try_wait() + .expect("fixture wait must succeed") + .is_some(), + "response fixture must be reaped before returning" + ); + } + /// [`spawn_script`], but blocks until the fixture has actually started. /// /// Deadline-sensitive tests must start their clock only once the shell is @@ -3418,7 +3431,7 @@ mod tests { echo '{"jsonrpc":"2.0","id":0,"method":"test/method","params":{}}' read -t 2 _reply echo '{"jsonrpc":"2.0","id":0,"result":{"ok":true}}' - sleep 1 + read -r _done "#; let mut client = spawn_script(script).await; let max_dur = std::time::Duration::from_secs(5); @@ -3434,6 +3447,7 @@ mod tests { .await; assert!(result.is_ok(), "expected Ok response, got {result:?}"); assert_eq!(result.unwrap()["ok"], serde_json::json!(true)); + shutdown_fixture(&mut client).await; } #[tokio::test] @@ -3520,7 +3534,7 @@ mod tests { echo '{"jsonrpc":"2.0","id":1,"method":"test/unknown","params":{}}' read -t 2 _err_reply echo '{"jsonrpc":"2.0","id":1,"result":{"worked":true}}' - sleep 1 + read -r _done "#; let mut client = spawn_script(script).await; // initialize consumes id=0 @@ -3535,6 +3549,7 @@ mod tests { .await; assert!(result.is_ok(), "expected Ok, got {result:?}"); assert_eq!(result.unwrap()["worked"], serde_json::json!(true)); + shutdown_fixture(&mut client).await; } /// Keepalive `session/update` lines must keep resetting the idle timer, so @@ -3599,7 +3614,7 @@ mod tests { echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' read -t 2 REQ echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_test","_receivedRequest":'"$REQ"'}}' - sleep 1 + read -r _done "#; let mut client = spawn_script(script).await; client @@ -3624,6 +3639,7 @@ mod tests { Some("Custom system prompt"), "systemPrompt should be included in params when Some" ); + shutdown_fixture(&mut client).await; } #[tokio::test] @@ -3631,7 +3647,7 @@ mod tests { let script = r#" read -t 2 REQ echo '{"jsonrpc":"2.0","id":0,"result":{"_receivedRequest":'"$REQ"'}}' - sleep 1 + read -r _done "#; let mut client = spawn_script(script).await; let result = client @@ -3647,6 +3663,7 @@ mod tests { assert_eq!(received["params"]["mode"], "set"); assert_eq!(received["params"]["key"], "buzz"); assert_eq!(received["params"]["text"], "Be terse"); + shutdown_fixture(&mut client).await; } #[tokio::test] @@ -3654,7 +3671,7 @@ mod tests { let script = r#" read -t 2 _REQ echo '{"jsonrpc":"2.0","id":0,"error":{"code":-32601,"message":"Method not found"}}' - sleep 1 + read -r _done "#; let mut client = spawn_script(script).await; assert!(matches!( @@ -3663,6 +3680,7 @@ mod tests { .await, Err(AcpError::AgentError { code: -32601, .. }) )); + shutdown_fixture(&mut client).await; } #[tokio::test] @@ -3670,7 +3688,7 @@ mod tests { let script = r#" read -t 2 _REQ echo '{"jsonrpc":"2.0","id":0,"error":{"code":-32602,"message":"Invalid params"}}' - sleep 1 + read -r _done "#; let mut client = spawn_script(script).await; assert!(matches!( @@ -3679,6 +3697,7 @@ mod tests { .await, Err(AcpError::AgentError { code: -32602, .. }) )); + shutdown_fixture(&mut client).await; } #[tokio::test] @@ -3689,7 +3708,7 @@ mod tests { echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' read -t 2 REQ echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_test","_receivedRequest":'"$REQ"'}}' - sleep 1 + read -r _done "#; let mut client = spawn_script(script).await; client @@ -3708,6 +3727,7 @@ mod tests { received["params"]["systemPrompt"].is_null(), "systemPrompt should NOT be in params when value is None" ); + shutdown_fixture(&mut client).await; } #[tokio::test] @@ -3717,7 +3737,7 @@ mod tests { echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' read -t 2 REQ echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_test","_receivedRequest":'"$REQ"'}}' - sleep 1 + read -r _done "#; let mut client = spawn_script(script).await; client @@ -3736,6 +3756,7 @@ mod tests { Some("Fizz · #buzz-dev"), "title should ride in _meta.sessionTitle, out of band from the prompt" ); + shutdown_fixture(&mut client).await; } #[tokio::test] @@ -3745,7 +3766,7 @@ mod tests { echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' read -t 2 REQ echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_test","_receivedRequest":'"$REQ"'}}' - sleep 1 + read -r _done "#; let mut client = spawn_script(script).await; client @@ -3763,6 +3784,7 @@ mod tests { received["params"].get("_meta").is_none(), "_meta should be absent entirely, not an empty object or null" ); + shutdown_fixture(&mut client).await; } // ── claude-agent-acp _meta.systemPrompt transport ───────────────────── @@ -3776,7 +3798,7 @@ mod tests { echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' read -t 2 REQ echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_claude","_receivedRequest":'"$REQ"'}}' - sleep 1 + read -r _done "#; let mut client = spawn_script(script).await; client @@ -3804,6 +3826,7 @@ mod tests { Some("Be concise"), "_meta.systemPrompt.append must carry the prompt text" ); + shutdown_fixture(&mut client).await; } #[tokio::test] @@ -3815,7 +3838,7 @@ mod tests { echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' read -t 2 REQ echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_merged","_receivedRequest":'"$REQ"'}}' - sleep 1 + read -r _done "#; let mut client = spawn_script(script).await; client @@ -3844,6 +3867,7 @@ mod tests { Some("Fizz · #buzz-dev"), "_meta.sessionTitle must be present alongside systemPrompt" ); + shutdown_fixture(&mut client).await; } // ── Goose-native steer scaffold (PR follow-up to #1160) ────────────── @@ -4034,14 +4058,15 @@ mod tests { async fn native_steer_with_active_run_id_routes_response_to_ack() { // Script: pause briefly so the test task can install the steer // and we can be sure the response doesn't race ahead of the - // write — then emit the steer response (id=0 because next_id - // starts at 0 and the steer is the first request the read loop + // write. Consume that request, then emit the steer response (id=0 + // because next_id starts at 0 and the steer is the first request the read loop // writes), then idle. This is a JSON-RPC success response with // a `stopReason` payload (matching the shape goose uses for // steer responses in fake_llm.rs). let script = "sleep 0.5; \ + read -r _steer; \ echo '{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{\"stopReason\":\"end_turn\"}}'; \ - sleep 10"; + read -r _done"; let mut client = spawn_script(script).await; // Set active_run_id via a synthesized session_info_update so the @@ -4096,6 +4121,7 @@ mod tests { crate::pool::SteerAck::Success { .. } => {} other => panic!("expected SteerAck::Success, got {other:?}"), } + shutdown_fixture(&mut client).await; } /// Steer-success renewal keeps the turn alive past the original hard @@ -4196,11 +4222,11 @@ mod tests { // the backslashes intact, which MSYS accepts as a Win32 path. let script = format!( "read -r line; printf '%s' \"$line\" > '{capture}'; \ - printf '%s\\n' '{response}'; sleep 10", + printf '%s\\n' '{response}'; read -r _done", capture = crate::testshell::quote_for_shell(capture_path), response = response, ); - // READY-gated, because `run_one_steer` gives the fixture an 800ms idle + // READY-gated, because `run_one_steer` gives the fixture a three-second idle // budget and `spawn_script` would leave MSYS shell startup inside it. // Starting a real bash costs a large and load-dependent fraction of // that budget, so under a loaded suite the read loop idled out before @@ -4253,6 +4279,7 @@ mod tests { let ack = ack_rx .await .expect("ack oneshot must have received a SteerAck"); + shutdown_fixture(client).await; (std::fs::read_to_string(capture_path).ok(), ack) } @@ -4280,9 +4307,11 @@ mod tests { /// Run `initialize` against a script that replies with `init_result` as /// the JSON-RPC result, and return the resulting `steering_supported`. async fn steering_supported_after_initialize(init_result: &str) -> bool { + // Keep the shell itself waiting for teardown without an idle descendant + // that can retain the test process's inherited output handles. let script = format!( "read -r _init; printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{result}}}'; \ - sleep 5", + read -r _done", result = init_result, ); let mut client = spawn_script(&script).await; @@ -4290,7 +4319,17 @@ mod tests { .initialize() .await .expect("initialize should succeed"); - client.steering_supported() + let supported = client.steering_supported(); + client.shutdown().await; + assert!( + client + .child + .try_wait() + .expect("fixture wait must succeed") + .is_some(), + "initialize fixture must be reaped before returning" + ); + supported } /// Test 1a: an adapter advertising `_meta.steering.supported: true` @@ -4339,7 +4378,7 @@ mod tests { async fn load_session_supported_after_initialize(init_result: &str) -> bool { let script = format!( "read -r _init; printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{result}}}'; \\ - sleep 5", + read -r _done", result = init_result, ); let mut client = spawn_script(&script).await; @@ -4347,7 +4386,17 @@ mod tests { .initialize() .await .expect("initialize should succeed"); - client.load_session_supported() + let supported = client.load_session_supported(); + client.shutdown().await; + assert!( + client + .child + .try_wait() + .expect("fixture wait must succeed") + .is_some(), + "initialize fixture must be reaped before returning" + ); + supported } #[tokio::test] @@ -4394,7 +4443,7 @@ mod tests { read -t 2 REQ echo '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_restored","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"replay"}}}}' echo '{"jsonrpc":"2.0","id":1,"result":{"_receivedRequest":'"$REQ"'}}' - sleep 1 + read -r _done "#; let mut client = spawn_script(script).await; client @@ -4406,6 +4455,7 @@ mod tests { .session_load("ses_restored", "/tmp", vec![]) .await .expect("session_load should succeed after replayed update"); + shutdown_fixture(&mut client).await; } /// Test 2: no `active_run_id` + capability advertised → the bytes on the @@ -4806,7 +4856,7 @@ mod tests { ID=$(printf '%s' "$REQ" | sed -E 's/.*"id":([0-9]+).*/\1/') echo '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"wire-session","update":{"sessionUpdate":"usage_update","cost":{"amount":0.5,"currency":"USD"}}}}' echo '{"jsonrpc":"2.0","id":'"$ID"',"result":{"stopReason":"end_turn","usage":{"inputTokens":7,"outputTokens":3,"totalTokens":10,"cachedReadTokens":2}}}' - sleep 1 + read -r _done "#; let (mut client, dir) = spawn_named_script("claude-code", script).await; assert_eq!(client.standard_adapter, Some(StandardAdapterKind::Claude)); @@ -4829,6 +4879,15 @@ mod tests { assert_eq!(usage.turn_output_tokens, Some(3)); assert_eq!(usage.turn_cost_usd, Some(0.5)); assert_eq!(usage.cumulative_cost_usd, Some(0.5)); + client.shutdown().await; + assert!( + client + .child + .try_wait() + .expect("fixture wait must succeed") + .is_some(), + "named wire fixture must be reaped before returning" + ); drop(client); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 19400aeb5d2..e68a1e567b6 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3208,6 +3208,7 @@ async fn tokio_main() -> Result<()> { Panic(tokio::task::JoinError), SteerAck(SteerAckEvent), Wake(u32, Result), + HoldDeadline, } loop { @@ -3349,6 +3350,8 @@ async fn tokio_main() -> Result<()> { } // Borrow result_rx and join_set simultaneously via split-borrow helper. + pool.retain_held_scopes(|scope| queue.has_pending_scope(scope)); + let hold_deadline = pool.next_hold_deadline(pool::HOLD_BUSY_OWNER_TIMEOUT); let pool_event: Option = { let (result_rx, join_set) = pool.rx_and_join_set(); tokio::select! { @@ -3391,6 +3394,9 @@ async fn tokio_main() -> Result<()> { _ => std::future::pending().await, } } => None, + _ = pool::AgentPool::wait_for_hold_deadline(hold_deadline), if pool_ready => { + Some(PoolEvent::HoldDeadline) + }, Some(Err(error)) = wake_tasks.join_next(), if !wake_tasks.is_empty() => { if let Some(attempt) = pool_lifecycle.waking_attempt() { let message = format!("pool wake task failed: {error}"); @@ -4285,6 +4291,21 @@ async fn tokio_main() -> Result<()> { } } } + Some(PoolEvent::HoldDeadline) => { + // A held thread must make progress even when every unrelated + // relay/timer source is quiet. The deadline is derived from + // the pool's first-held stamp, so this dispatch observes + // `ForkAfterHold` and claims an idle worker immediately. + for (scope, thread_tags) in dispatch_pending( + &mut pool, + &mut queue, + &ctx, + &mut last_activity, + observer.as_ref(), + ) { + typing_channels.insert(scope, thread_tags); + } + } None => {} // relay/heartbeat/shutdown branches handled inline above } } @@ -4676,7 +4697,7 @@ fn dispatch_pending( let mut held: Vec = Vec::new(); // One clock read for the whole cycle so every batch's bounded-hold window is // measured against the same instant. - let now = std::time::Instant::now(); + let now = tokio::time::Instant::now(); loop { let batch = match queue.flush_next() { Some(b) => b, @@ -4691,7 +4712,8 @@ fn dispatch_pending( // so an active channel cannot starve a sibling channel on a shared // worker. A held thread that outwaits the window forks a fresh session // rather than starve behind an unbounded turn. - match pool.hold_decision(&scope, now, pool::HOLD_BUSY_OWNER_TIMEOUT) { + let forked_after_hold = match pool.hold_decision(&scope, now, pool::HOLD_BUSY_OWNER_TIMEOUT) + { pool::HoldDecision::Hold { held_for, owner_index, @@ -4722,31 +4744,9 @@ fn dispatch_pending( pool::HoldDecision::ForkAfterHold { held_for, owner_index, - } => { - tracing::warn!( - channel = %channel_id, - scope = %scope.telemetry_label(), - owner_index, - held_for_secs = held_for.as_secs_f64(), - "busy-owner hold expired — forking fresh session on an idle worker" - ); - if let Some(observer) = observer { - observer.emit( - "busy_owner_hold_forked", - None, - &observer::context_for(Some(channel_id), None, None), - serde_json::json!({ - "scope": scope.telemetry_label(), - "ownerIndex": owner_index, - "heldForSecs": held_for.as_secs_f64(), - }), - ); - } - // Fall through to try_claim below (fork); record_scope_owner - // reassigns ownership to the new worker automatically. - } - pool::HoldDecision::Dispatch => {} - } + } => Some((held_for, owner_index)), + pool::HoldDecision::Dispatch => None, + }; let typing_scope = batch .events .last() @@ -4766,6 +4766,32 @@ fn dispatch_pending( break; } }; + // Consume a bounded hold only after a worker was actually claimed. + // If every slot is checked out, the expired stamp remains sticky and + // the worker-return event retries immediately instead of waiting for a + // fresh timeout window. + pool.clear_hold(&scope); + if let Some((held_for, owner_index)) = forked_after_hold { + tracing::warn!( + channel = %channel_id, + scope = %scope.telemetry_label(), + owner_index, + held_for_secs = held_for.as_secs_f64(), + "busy-owner hold expired — forking fresh session on an idle worker" + ); + if let Some(observer) = observer { + observer.emit( + "busy_owner_hold_forked", + None, + &observer::context_for(Some(channel_id), None, None), + serde_json::json!({ + "scope": scope.telemetry_label(), + "ownerIndex": owner_index, + "heldForSecs": held_for.as_secs_f64(), + }), + ); + } + } tracing::debug!(agent = agent.index, channel = %channel_id, scope = %scope.telemetry_label(), affinity_hit, "agent_claimed"); let recoverable_batch = match ctx.dedup_mode { @@ -4796,6 +4822,14 @@ fn dispatch_pending( let turn_id = Uuid::new_v4().to_string(); let task_turn_id = turn_id.clone(); + // Assign ownership before moving the worker into the task. If this is + // a bounded-hold fork, the new generation immediately invalidates the + // prior busy worker's copy when that worker eventually returns. + let owner_generation = pool.record_scope_owner(scope.clone(), agent.index); + agent + .state + .set_scope_owner_generation(scope.clone(), owner_generation); + let abort_handle = pool.join_set.spawn(async move { pool::run_prompt_task( agent, @@ -4822,9 +4856,6 @@ fn dispatch_pending( successful_steer_deliveries: HashSet::new(), }, ); - // Record this worker as the scope's session owner so a later dispatch - // while it is busy holds instead of forking a duplicate session. - pool.record_scope_owner(scope.clone(), agent_index); dispatched_channels.push((scope, typing_scope)); *last_activity = tokio::time::Instant::now(); } @@ -5016,6 +5047,23 @@ fn handle_prompt_result( } else { hard_timeout_fate_suffix = Some(" — requeued for retry (recently active)"); } + } else if matches!( + &result.outcome, + PromptOutcome::Error(acp::AcpError::AgentError { code: -32002, message }) + if message.contains("model not found") + ) { + // Retrying the same missing model cannot repair its configuration. + tracing::warn!( + channel_id = %batch.channel_id, + events = batch.events.len(), + "dead-lettering batch immediately — model not found" + ); + let content = "⚠️ I couldn't process the last request: the configured model \ + wasn't found at the provider's endpoint. Open agent settings, select a \ + different model from the dropdown, and save your changes. Restart the agent \ + to apply the new configuration, then re-send your request." + .to_string(); + spawn_failure_notice(rest_client, &batch, content); } else if matches!(&result.outcome, PromptOutcome::Error(e) if is_auth_error(e)) { // Auth errors are non-retryable: the token won't self-repair // between retries, so requeueing only wastes attempt slots and @@ -6549,7 +6597,7 @@ mod owner_control_command_tests { // The bounded hold decision stamps A's first-held time, then forks once // the window elapses rather than starving behind the busy owner. - let now = std::time::Instant::now(); + let now = tokio::time::Instant::now(); assert!( matches!( pool.hold_decision(&ta, now, pool::HOLD_BUSY_OWNER_TIMEOUT), @@ -6570,9 +6618,10 @@ mod owner_control_command_tests { "elapsed window => fork on an idle worker" ); assert!( - !pool.held_since_contains(&ta), - "fork clears the first-held stamp" + pool.held_since_contains(&ta), + "expired hold stays sticky until an idle worker is claimed" ); + pool.clear_hold(&ta); // A conversation scope never holds even with a busy recorded owner — // this is the cross-channel head-of-line-blocking regression guard. @@ -6604,6 +6653,55 @@ mod owner_control_command_tests { ); } + #[tokio::test] + async fn queue_cap_eviction_prunes_orphaned_hold_deadline() { + let mut pool = AgentPool::from_slots(vec![]); + let mut queue = EventQueue::new(DedupMode::Queue); + let channel_id = Uuid::new_v4(); + let held_scope = thread_scope(channel_id, &"a".repeat(64)); + let surviving_scope = thread_scope(channel_id, &"b".repeat(64)); + + pool.record_scope_owner(held_scope.clone(), 0); + let (tx, _rx) = tokio::sync::oneshot::channel(); + insert_task_meta(&mut pool, 0, surviving_scope.clone(), tx); + assert!(matches!( + pool.hold_decision( + &held_scope, + tokio::time::Instant::now(), + pool::HOLD_BUSY_OWNER_TIMEOUT + ), + pool::HoldDecision::Hold { .. } + )); + + let oldest = std::time::Instant::now() - Duration::from_secs(1); + queue.push(queue::QueuedEvent { + channel_id, + scope: held_scope.clone(), + event: make_event(KIND_STREAM_MESSAGE, "held", None), + received_at: oldest, + prompt_tag: "test".into(), + }); + for i in 0..500 { + queue.push(queue::QueuedEvent { + channel_id, + scope: surviving_scope.clone(), + event: make_event(KIND_STREAM_MESSAGE, &format!("new-{i}"), None), + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + }); + } + + assert!( + !queue.has_pending_scope(&held_scope), + "aggregate cap evicts the globally oldest scope" + ); + pool.retain_held_scopes(|scope| queue.has_pending_scope(scope)); + assert!( + !pool.held_since_contains(&held_scope), + "evicted scope cannot leave an immediately-ready deadline behind" + ); + } + #[test] fn project_owner_control_signs_only_addressable_project_events() { let keys = Keys::generate(); @@ -9739,6 +9837,15 @@ mod error_outcome_emission_tests { } } + fn bind_agent_scope_owner( + pool: &mut AgentPool, + agent: &mut OwnedAgent, + scope: scope::SessionScope, + ) { + let generation = pool.record_scope_owner(scope.clone(), agent.index); + agent.state.set_scope_owner_generation(scope, generation); + } + #[tokio::test] async fn successful_native_steer_is_transferred_to_live_session_delivery_state() { let channel_id = Uuid::new_v4(); @@ -9754,6 +9861,11 @@ mod error_outcome_emission_tests { ); let mut pool = AgentPool::from_slots(vec![None]); + bind_agent_scope_owner( + &mut pool, + &mut agent, + scope::SessionScope::Conversation { channel_id }, + ); let task_id = pool.join_set.spawn(async {}).id(); pool.task_map_mut().insert( task_id, @@ -9829,6 +9941,11 @@ mod error_outcome_emission_tests { ); let mut pool = AgentPool::from_slots(vec![None]); + bind_agent_scope_owner( + &mut pool, + &mut agent, + scope::SessionScope::Conversation { channel_id }, + ); let task_id = pool.join_set.spawn(async {}).id(); pool.task_map_mut().insert( task_id, @@ -11066,6 +11183,7 @@ mod error_outcome_emission_tests { .sessions .insert(session_scope.clone(), "healthy-session".into()); let mut pool = AgentPool::from_slots(vec![None]); + bind_agent_scope_owner(&mut pool, &mut agent, session_scope.clone()); let task_id = pool.join_set.spawn(async {}).id(); pool.task_map_mut().insert( task_id, @@ -11280,10 +11398,192 @@ mod error_outcome_emission_tests { ); } + #[tokio::test] + async fn model_not_found_posts_recovery_notice_without_retrying() { + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let rest = relay::RestClient { + http: reqwest::Client::new(), + base_url: format!("http://{}", listener.local_addr().unwrap()), + keys: Keys::generate(), + auth_tag_json: None, + }; + let keys = Keys::generate(); + let root = nostr::EventId::from_byte_array([0xaa; 32]); + let parent = nostr::EventId::from_byte_array([0xbb; 32]); + let event = EventBuilder::new(Kind::Custom(9), "test") + .tags([ + nostr::Tag::parse(["e", &root.to_hex(), "", "root"]).unwrap(), + nostr::Tag::parse(["e", &parent.to_hex(), "", "reply"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let channel_id = uuid::Uuid::new_v4(); + let batch = FlushBatch { + channel_id, + scope: scope::SessionScope::Conversation { channel_id }, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + let raw_error = r#"llm model not found: (gpt-6-astra) 404 Not Found: {"error_code":"NOT_FOUND","message":"'gpt-6-astra' does not exist."}"#; + let model_error = AcpError::AgentError { + code: -32002, + message: raw_error.to_string(), + }; + let expected_error = model_error.to_string(); + let observer = ObserverHandle::in_process(); + + let agent = dummy_agent(0).await; + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: None, + scope: None, + turn_id: "test-turn-id".to_string(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + }, + ); + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = std::collections::HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let result = PromptResult { + agent, + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), + turn_id: "test-turn-id".to_string(), + outcome: PromptOutcome::Error(model_error), + batch: Some(batch), + }; + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + Some(observer.clone()), + Some(&rest), + ); + + // The batch must not be requeued: pending_channels returns 0. + assert_eq!( + queue.pending_channels(), + 0, + "model-not-found must stop immediately — batch must not be requeued" + ); + assert_eq!( + queue.queued_event_count(channel_id), + 0, + "model-not-found must stop immediately — no events should be pending" + ); + + assert!( + pool.agents_mut()[0].is_some(), + "healthy process remains reusable" + ); + assert!(respawn_tasks.is_empty()); + let errors: Vec<_> = observer + .snapshot() + .into_iter() + .filter(|event| event.kind == "turn_error") + .collect(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].payload["code"], -32002); + assert_eq!(errors[0].payload["error"], expected_error); + + // Capture the real signed notice sent by handle_prompt_result, without a live relay. + let notice: nostr::Event = tokio::time::timeout(Duration::from_secs(3), async { + let (socket, _) = listener.accept().await.unwrap(); + let mut reader = BufReader::new(socket); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + assert_eq!(line, "POST /events HTTP/1.1\r\n"); + let mut content_length = None; + for _ in 0..64 { + line.clear(); + assert_ne!(reader.read_line(&mut line).await.unwrap(), 0); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + content_length = Some(value.trim().parse::().unwrap()); + } + } + let size = content_length.expect("request Content-Length"); + assert!(size < 65536); + let mut body = vec![0; size]; + reader.read_exact(&mut body).await.unwrap(); + reader + .get_mut() + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}") + .await + .unwrap(); + serde_json::from_slice(&body).unwrap() + }) + .await + .expect("failure notice must be posted on the first failure"); + notice.verify().unwrap(); + assert_eq!(notice.pubkey, rest.keys.public_key()); + assert_eq!(notice.kind, Kind::Custom(9)); + assert_eq!( + notice.content, + "⚠️ I couldn't process the last request: the configured model wasn't found at the provider's endpoint. Open agent settings, select a different model from the dropdown, and save your changes. Restart the agent to apply the new configuration, then re-send your request." + ); + let tags = serde_json::to_value(¬ice.tags).unwrap(); + assert!(tags + .as_array() + .unwrap() + .iter() + .any(|tag| tag[0] == "h" && tag[1] == channel_id.to_string())); + let threading = queue::parse_thread_tags(¬ice); + assert_eq!(threading.root_event_id, Some(root.to_hex())); + assert_eq!(threading.parent_event_id, Some(parent.to_hex())); + } + /// A non-auth application error (e.g. usage credits) must still follow the /// standard requeue path so today's behavior is unchanged. #[tokio::test] async fn non_auth_application_error_is_requeued() { + assert_application_error_is_requeued(acp::AcpError::AgentError { + code: -32000, + message: "Usage credits required for 1M context".to_string(), + }) + .await; + } + + #[tokio::test] + async fn non_model_resource_not_found_is_requeued() { + assert_application_error_is_requeued(acp::AcpError::AgentError { + code: -32002, + message: "Resource not found: session no longer exists".to_string(), + }) + .await; + } + + async fn assert_application_error_is_requeued(error: acp::AcpError) { let keys = nostr::Keys::generate(); let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "test") .sign_with_keys(&keys) @@ -11301,12 +11601,6 @@ mod error_outcome_emission_tests { cancel_reason: None, }; - // Usage-credits error — AgentError but NOT an auth error. - let usage_error = acp::AcpError::AgentError { - code: -32000, - message: "Usage credits required for 1M context".to_string(), - }; - let agent = dummy_agent(0).await; let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -11338,7 +11632,7 @@ mod error_outcome_emission_tests { agent, source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), - outcome: PromptOutcome::Error(usage_error), + outcome: PromptOutcome::Error(error), batch: Some(batch), }; handle_prompt_result( diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 03318d1f62e..10e2fc0079a 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -141,9 +141,18 @@ pub struct SessionState { /// Per-scope successful-delivery state. Created with the ACP session and /// cleared atomically with every invalidation path. pub deliveries: HashMap, + /// Pool-assigned ownership generation for each scope. A worker returning + /// after another worker forked the scope carries an older generation; the + /// pool uses this fence to discard that stale provider session before the + /// worker becomes claimable again. + scope_owner_generations: HashMap, } impl SessionState { + pub(crate) fn set_scope_owner_generation(&mut self, scope: SessionScope, generation: u64) { + self.scope_owner_generations.insert(scope, generation); + } + /// Invalidate the session (and turn counter) for a specific prompt source. pub fn invalidate(&mut self, source: &PromptSource) { match source { @@ -165,6 +174,7 @@ impl SessionState { self.core_sections.remove(scope); self.canvas_sections.remove(scope); self.deliveries.remove(scope); + self.scope_owner_generations.remove(scope); self.sessions.remove(scope).is_some() } @@ -179,6 +189,7 @@ impl SessionState { .chain(self.core_sections.keys()) .chain(self.canvas_sections.keys()) .chain(self.deliveries.keys()) + .chain(self.scope_owner_generations.keys()) .filter(|s| s.channel_id() == *channel_id) .cloned() .collect::>() @@ -203,6 +214,7 @@ impl SessionState { self.core_sections.clear(); self.canvas_sections.clear(); self.deliveries.clear(); + self.scope_owner_generations.clear(); } pub(crate) fn mark_scope_delivery_success( @@ -336,13 +348,23 @@ pub struct AgentPool { /// cause another worker to open a duplicate session for the same thread. /// Best-effort: stale entries (rotation, crash/respawn) self-heal on the /// next dispatch and are pruned on channel-wide session invalidation. - session_owners: HashMap, + session_owners: HashMap, + /// Monotonic validity fence assigned whenever a scope is dispatched. The + /// generation distinguishes a newly forked owner from every older copy of + /// that scope's provider session. + next_scope_owner_generation: u64, /// First time each scope was held for a busy owner, so the bounded hold can /// expire and fork rather than starve behind an unbounded turn. Derived /// state: cleared on every dispatch/invalidation path, and only ever holds /// `Thread` scopes (the sole variant [`hold_decision`](Self::hold_decision) /// stamps). - held_since: HashMap, + held_since: HashMap, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct SessionOwner { + agent_index: usize, + generation: u64, } /// Result returned by a completed prompt task. @@ -833,14 +855,33 @@ impl AgentPool { join_set: JoinSet::new(), task_map: HashMap::new(), session_owners: HashMap::new(), + next_scope_owner_generation: 1, held_since: HashMap::new(), } } - /// Record which worker is handling `scope` so a later dispatch can detect a - /// busy owner and avoid opening a duplicate session on another worker. - pub fn record_scope_owner(&mut self, scope: SessionScope, agent_index: usize) { - self.session_owners.insert(scope, agent_index); + /// Record `agent_index` as the newest owner of `scope`, returning the + /// generation that the caller must install on the checked-out worker. + /// Returning workers are accepted only while this exact + /// `(worker, generation)` pair remains authoritative. + pub fn record_scope_owner(&mut self, scope: SessionScope, agent_index: usize) -> u64 { + let generation = self.next_scope_owner_generation; + self.next_scope_owner_generation = self.next_scope_owner_generation.wrapping_add(1); + if self.next_scope_owner_generation == 0 { + // Preserve zero as an unassigned sentinel. Reaching this requires + // 2^64 dispatches in one process, but resetting safely is cheap: + // every previously tagged session becomes stale on return/claim. + self.next_scope_owner_generation = 1; + self.session_owners.clear(); + } + self.session_owners.insert( + scope, + SessionOwner { + agent_index, + generation, + }, + ); + generation } /// True when this scope should be **held** (left queued) rather than @@ -857,16 +898,21 @@ impl AgentPool { return false; } match self.session_owners.get(scope) { - Some(&owner_idx) => self.task_map.values().any(|m| m.agent_index == owner_idx), + Some(owner) => self + .task_map + .values() + .any(|m| m.agent_index == owner.agent_index), None => false, } } /// Decide whether to hold `scope`'s batch for its busy session owner, fork it - /// after a bounded hold, or dispatch immediately. Stamps and clears the - /// first-held time internally so the bounded window survives across dispatch - /// cycles without a dedicated timer; `now` and `timeout` are injected for - /// testability. + /// after a bounded hold, or dispatch immediately. Stamps the first-held time + /// so the bounded window survives across dispatch cycles; `now` and + /// `timeout` are injected for testability. An expired + /// stamp remains sticky until [`clear_hold`](Self::clear_hold) confirms a + /// worker was successfully claimed, so pool exhaustion cannot restart the + /// bounded window. /// /// Gated on the scope variant, not the session policy: `Conversation` scopes /// (channel-policy channels and all DMs) never hold — a busy owner there means @@ -876,18 +922,21 @@ impl AgentPool { pub fn hold_decision( &mut self, scope: &SessionScope, - now: std::time::Instant, + now: tokio::time::Instant, timeout: Duration, ) -> HoldDecision { if !scope.is_thread() || !self.should_hold_for_busy_owner(scope) { self.held_since.remove(scope); return HoldDecision::Dispatch; } - let owner_index = self.session_owners.get(scope).copied().unwrap_or_default(); + let owner_index = self + .session_owners + .get(scope) + .map(|owner| owner.agent_index) + .unwrap_or_default(); let first = *self.held_since.entry(scope.clone()).or_insert(now); let held_for = now.saturating_duration_since(first); if held_for >= timeout { - self.held_since.remove(scope); HoldDecision::ForkAfterHold { held_for, owner_index, @@ -914,7 +963,7 @@ impl AgentPool { if let Some(scope) = scope { let idx = self.agents.iter().position(|slot| { slot.as_ref() - .map(|a| a.state.sessions.contains_key(scope)) + .map(|a| self.agent_owns_scope(a, scope)) .unwrap_or(false) }); if let Some(i) = idx { @@ -928,7 +977,27 @@ impl AgentPool { } /// Return an agent to its slot after a task completes. - pub fn return_agent(&mut self, agent: OwnedAgent) { + pub fn return_agent(&mut self, mut agent: OwnedAgent) { + let stale_scopes: Vec = agent + .state + .sessions + .keys() + .filter(|scope| !self.agent_owns_scope(&agent, scope)) + .cloned() + .collect(); + for scope in stale_scopes { + tracing::info!( + agent = agent.index, + scope = %scope.telemetry_label(), + "discarding stale session after ownership changed" + ); + agent.state.invalidate_scope(&scope); + } + let live_scopes: HashSet = agent.state.sessions.keys().cloned().collect(); + agent + .state + .scope_owner_generations + .retain(|scope, _| live_scopes.contains(scope)); let idx = agent.index; if self.agents[idx].is_some() { // This is a bug: two tasks returned the same agent index. Log it @@ -948,16 +1017,64 @@ impl AgentPool { self.agents.iter().any(|slot| slot.is_some()) } + /// Confirm that pending work for `scope` successfully claimed a worker. + /// + /// In particular, an expired busy-owner hold must not be consumed until + /// this point: `try_claim` can fail while every worker remains checked out. + pub(crate) fn clear_hold(&mut self, scope: &SessionScope) { + self.held_since.remove(scope); + } + + /// Remove derived hold stamps for scopes that no longer have pending work. + pub(crate) fn retain_held_scopes( + &mut self, + mut has_pending_work: impl FnMut(&SessionScope) -> bool, + ) { + self.held_since.retain(|scope, _| has_pending_work(scope)); + } + /// Whether any idle agent already has a session for `scope`. /// Used to compute `affinity_hit` before calling `try_claim`. pub fn has_session_for(&self, scope: &SessionScope) -> bool { self.agents.iter().any(|slot| { slot.as_ref() - .map(|a| a.state.sessions.contains_key(scope)) + .map(|a| self.agent_owns_scope(a, scope)) .unwrap_or(false) }) } + fn agent_owns_scope(&self, agent: &OwnedAgent, scope: &SessionScope) -> bool { + let Some(owner) = self.session_owners.get(scope) else { + return false; + }; + owner.agent_index == agent.index + && agent.state.scope_owner_generations.get(scope) == Some(&owner.generation) + && agent.state.sessions.contains_key(scope) + } + + /// Earliest scheduled wake for a currently held scope that can claim a + /// worker. A worker return wakes the main loop independently, so arming an + /// already-expired timer while every slot is checked out would only spin. + pub(crate) fn next_hold_deadline(&self, timeout: Duration) -> Option { + if !self.any_idle() { + return None; + } + self.held_since + .values() + .map(|held_since| *held_since + timeout) + .min() + } + + /// Sleep until a held scope's scheduled wake, or remain pending when no + /// scope is held. This is the future polled directly by the main + /// `select!`, kept here so paused-time tests exercise the production seam. + pub(crate) async fn wait_for_hold_deadline(deadline: Option) { + match deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => std::future::pending().await, + } + } + /// Count of agents that are alive: idle OR checked out (have a task_map entry). /// /// Used to detect when all agents have exited so the caller can respawn. @@ -7952,9 +8069,14 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let ch = Uuid::new_v4(); let ta = thread_scope(ch, &"a".repeat(64)); let tb = thread_scope(ch, &"b".repeat(64)); - let acp = AcpClient::spawn("bash", &["-c".into(), "sleep 10".into()], &[], false) - .await - .expect("spawn dummy ACP"); + let acp = AcpClient::spawn( + &crate::testshell::posix_shell_command(), + &["-c".into(), "sleep 10".into()], + &[], + false, + ) + .await + .expect("spawn dummy ACP"); let mut agent = OwnedAgent { index: 0, acp, @@ -7972,9 +8094,16 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" agent.state.sessions.insert(ta.clone(), "sess-a".into()); agent.state.sessions.insert(tb.clone(), "sess-b".into()); let mut pool = AgentPool::from_slots(vec![Some(agent)]); - pool.record_scope_owner(ta.clone(), 0); - pool.record_scope_owner(tb.clone(), 0); - let now = std::time::Instant::now(); + let ta_generation = pool.record_scope_owner(ta.clone(), 0); + let tb_generation = pool.record_scope_owner(tb.clone(), 0); + let agent = pool.agents[0].as_mut().expect("idle test agent"); + agent + .state + .set_scope_owner_generation(ta.clone(), ta_generation); + agent + .state + .set_scope_owner_generation(tb.clone(), tb_generation); + let now = tokio::time::Instant::now(); pool.held_since.insert(ta.clone(), now); pool.held_since.insert(tb.clone(), now); @@ -8026,12 +8155,17 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" /// An idle agent (slot 0) holding a provider session for `scope`, so /// `has_session_for(scope)` is true. - async fn idle_agent_with_session(scope: SessionScope) -> OwnedAgent { - let acp = AcpClient::spawn("bash", &["-c".into(), "sleep 10".into()], &[], false) - .await - .expect("spawn dummy ACP"); + async fn idle_agent_with_session(index: usize, scope: SessionScope) -> OwnedAgent { + let acp = AcpClient::spawn( + &crate::testshell::posix_shell_command(), + &["-c".into(), "sleep 10".into()], + &[], + false, + ) + .await + .expect("spawn dummy ACP"); let mut agent = OwnedAgent { - index: 0, + index, acp, state: SessionState::default(), model_capabilities: None, @@ -8119,7 +8253,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" }, ]; - let base = std::time::Instant::now(); + let base = tokio::time::Instant::now(); for row in rows { let ch = Uuid::new_v4(); let scope = if row.is_thread { @@ -8128,12 +8262,19 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" conv(ch) }; let slots = if row.has_session { - vec![Some(idle_agent_with_session(scope.clone()).await)] + vec![Some(idle_agent_with_session(0, scope.clone()).await)] } else { vec![] }; let mut pool = AgentPool::from_slots(slots); - if row.owner_busy { + if row.has_session { + let generation = pool.record_scope_owner(scope.clone(), 0); + pool.agents[0] + .as_mut() + .expect("idle test agent") + .state + .set_scope_owner_generation(scope.clone(), generation); + } else if row.owner_busy { pool.record_scope_owner(scope.clone(), 1); mark_agent_busy(&mut pool, 1, thread_scope(ch, &"b".repeat(64))); } @@ -8159,8 +8300,12 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" _ => panic!("{}: expected {:?}, got {decision:?}", row.name, row.expect), } - // held_since holds the scope only while a Hold is outstanding. - if matches!(decision, HoldDecision::Hold { .. }) { + // An expired hold remains sticky until a worker is successfully + // claimed; only immediate dispatch clears it here. + if matches!( + decision, + HoldDecision::Hold { .. } | HoldDecision::ForkAfterHold { .. } + ) { assert!( pool.held_since.contains_key(&scope), "{}: hold stamps held_since", @@ -8176,6 +8321,159 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" } } + #[tokio::test(start_paused = true)] + async fn held_scope_deadline_wakes_a_quiet_dispatch_loop() { + let channel_id = Uuid::new_v4(); + let scope = thread_scope(channel_id, &"a".repeat(64)); + let idle_scope = thread_scope(channel_id, &"c".repeat(64)); + let idle_agent = idle_agent_with_session(0, idle_scope).await; + let mut pool = AgentPool::from_slots(vec![Some(idle_agent)]); + pool.record_scope_owner(scope.clone(), 1); + mark_agent_busy(&mut pool, 1, thread_scope(channel_id, &"b".repeat(64))); + + let started = tokio::time::Instant::now(); + assert!(matches!( + pool.hold_decision(&scope, started, HOLD_BUSY_OWNER_TIMEOUT), + HoldDecision::Hold { .. } + )); + let deadline = pool + .next_hold_deadline(HOLD_BUSY_OWNER_TIMEOUT) + .expect("held scope schedules an independent wake"); + let wake = AgentPool::wait_for_hold_deadline(Some(deadline)); + tokio::pin!(wake); + + tokio::time::advance(HOLD_BUSY_OWNER_TIMEOUT - Duration::from_millis(1)).await; + assert!( + tokio::time::timeout(Duration::ZERO, &mut wake) + .await + .is_err(), + "quiet loop stays asleep before deadline" + ); + tokio::time::advance(Duration::from_millis(1)).await; + wake.await; + + assert!(matches!( + pool.hold_decision(&scope, tokio::time::Instant::now(), HOLD_BUSY_OWNER_TIMEOUT), + HoldDecision::ForkAfterHold { .. } + )); + } + + #[tokio::test] + async fn expired_hold_survives_pool_exhaustion_until_a_worker_is_claimable() { + let channel_id = Uuid::new_v4(); + let scope = thread_scope(channel_id, &"a".repeat(64)); + let mut pool = AgentPool::from_slots(vec![None]); + pool.record_scope_owner(scope.clone(), 1); + mark_agent_busy(&mut pool, 1, thread_scope(channel_id, &"b".repeat(64))); + + let started = tokio::time::Instant::now(); + assert!(matches!( + pool.hold_decision(&scope, started, HOLD_BUSY_OWNER_TIMEOUT), + HoldDecision::Hold { .. } + )); + assert!(matches!( + pool.hold_decision( + &scope, + started + HOLD_BUSY_OWNER_TIMEOUT, + HOLD_BUSY_OWNER_TIMEOUT + ), + HoldDecision::ForkAfterHold { .. } + )); + assert!( + pool.held_since.contains_key(&scope), + "failed claim must not restart the timeout" + ); + assert_eq!( + pool.next_hold_deadline(HOLD_BUSY_OWNER_TIMEOUT), + None, + "an expired hold cannot spin while all workers are checked out" + ); + + let idle_scope = thread_scope(channel_id, &"c".repeat(64)); + pool.agents[0] = Some(idle_agent_with_session(0, idle_scope).await); + assert_eq!( + pool.next_hold_deadline(HOLD_BUSY_OWNER_TIMEOUT), + Some(started + HOLD_BUSY_OWNER_TIMEOUT), + "worker availability immediately re-arms the expired deadline" + ); + assert!(matches!( + pool.hold_decision( + &scope, + started + HOLD_BUSY_OWNER_TIMEOUT + Duration::from_secs(1), + HOLD_BUSY_OWNER_TIMEOUT + ), + HoldDecision::ForkAfterHold { .. } + )); + pool.clear_hold(&scope); + assert!(!pool.held_since.contains_key(&scope)); + } + + #[tokio::test] + async fn forked_scope_discards_stale_session_when_busy_owner_returns() { + let channel_id = Uuid::new_v4(); + let scope = thread_scope(channel_id, &"a".repeat(64)); + let busy_scope = thread_scope(channel_id, &"b".repeat(64)); + let old_owner = idle_agent_with_session(0, scope.clone()).await; + let replacement = idle_agent_with_session(1, busy_scope.clone()).await; + let mut pool = AgentPool::from_slots(vec![Some(old_owner), Some(replacement)]); + + let mut old_owner = pool.try_claim(None).expect("claim worker 0"); + let old_generation = pool.record_scope_owner(scope.clone(), old_owner.index); + old_owner + .state + .set_scope_owner_generation(scope.clone(), old_generation); + mark_agent_busy(&mut pool, old_owner.index, busy_scope); + + let started = tokio::time::Instant::now(); + assert!(matches!( + pool.hold_decision(&scope, started, HOLD_BUSY_OWNER_TIMEOUT), + HoldDecision::Hold { .. } + )); + assert!(matches!( + pool.hold_decision( + &scope, + started + HOLD_BUSY_OWNER_TIMEOUT, + HOLD_BUSY_OWNER_TIMEOUT + ), + HoldDecision::ForkAfterHold { .. } + )); + + let mut replacement = pool + .try_claim(Some(&scope)) + .expect("idle worker receives forked scope"); + pool.clear_hold(&scope); + assert_eq!(replacement.index, 1); + replacement + .state + .sessions + .insert(scope.clone(), "fresh-session".into()); + let fresh_generation = pool.record_scope_owner(scope.clone(), replacement.index); + replacement + .state + .set_scope_owner_generation(scope.clone(), fresh_generation); + + // Both turns return. Slot order must not make worker 0's old provider + // context claimable after worker 1 became the authoritative owner. + pool.return_agent(replacement); + pool.task_map + .retain(|_, meta| meta.agent_index != old_owner.index); + pool.return_agent(old_owner); + assert!( + !pool.agents[0] + .as_ref() + .expect("worker 0 returned") + .state + .sessions + .contains_key(&scope), + "return cleanup removes the old provider session" + ); + + let claimed = pool + .try_claim(Some(&scope)) + .expect("authoritative owner remains claimable"); + assert_eq!(claimed.index, 1, "next turn resumes the forked session"); + } + #[test] fn test_rotate_after_natural_completion_invalidates_channel_state() { let (mut s, ch_a, ch_b) = make_state(); @@ -8915,7 +9213,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[tokio::test] async fn test_send_prompt_result_clears_steer_rx_on_early_return() { let acp = AcpClient::spawn( - "bash", + &crate::testshell::posix_shell_command(), &["-c".to_string(), "sleep 10".to_string()], &[], false, @@ -8976,7 +9274,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[tokio::test] async fn test_send_prompt_result_is_noop_when_steer_rx_already_consumed() { let acp = AcpClient::spawn( - "bash", + &crate::testshell::posix_shell_command(), &["-c".to_string(), "sleep 10".to_string()], &[], false, @@ -9558,9 +9856,14 @@ done"#, quoted = quoted, body = script, ); - let acp = AcpClient::spawn("bash", &["-c".to_string(), wrapped], &[], false) - .await - .expect("spawn capture agent"); + let acp = AcpClient::spawn( + &crate::testshell::posix_shell_command(), + &["-c".to_string(), wrapped], + &[], + false, + ) + .await + .expect("spawn capture agent"); (acp, capture) } @@ -10680,9 +10983,14 @@ done"#, printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}' done"# ); - let acp = AcpClient::spawn("bash", &["-c".into(), script], &[], false) - .await - .expect("spawn wire-capture ACP"); + let acp = AcpClient::spawn( + &crate::testshell::posix_shell_command(), + &["-c".into(), script], + &[], + false, + ) + .await + .expect("spawn wire-capture ACP"); let agent = OwnedAgent { index: 0, acp, @@ -11112,9 +11420,14 @@ while IFS= read -r line; do fi done"# ); - AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) - .await - .expect("spawn effort ACP script") + AcpClient::spawn( + &crate::testshell::posix_shell_command(), + &["-c".to_string(), script], + &[], + false, + ) + .await + .expect("spawn effort ACP script") } fn captured_config_options(obs: &observer::ObserverHandle) -> serde_json::Value { @@ -11283,9 +11596,14 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"sessionId":"sess-1","configO IFS= read -r _effort exit 0"# ); - let acp = AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) - .await - .expect("spawn transport-exit ACP script"); + let acp = AcpClient::spawn( + &crate::testshell::posix_shell_command(), + &["-c".to_string(), script], + &[], + false, + ) + .await + .expect("spawn transport-exit ACP script"); let mut agent = effort_agent(acp, Some("high")); let ctx = make_prompt_context_no_owner(); @@ -11372,9 +11690,14 @@ while IFS= read -r line; do fi done"# ); - AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) - .await - .expect("spawn switch ACP script") + AcpClient::spawn( + &crate::testshell::posix_shell_command(), + &["-c".to_string(), script], + &[], + false, + ) + .await + .expect("spawn switch ACP script") } fn capture(obs: &observer::ObserverHandle) -> serde_json::Value { @@ -11536,7 +11859,7 @@ done"# pool.invalidate_scope_session(&scopes[1]); pool.record_scope_owner(scopes[0].clone(), 0); pool.held_since - .insert(scopes[0].clone(), std::time::Instant::now()); + .insert(scopes[0].clone(), tokio::time::Instant::now()); assert_eq!( pool.switch_idle_agent_model(channel_id, "model-b", Some("pick".into())), IdleSwitchResult::Switched, @@ -11822,9 +12145,14 @@ while IFS= read -r line; do fi done"# ); - AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) - .await - .expect("spawn switch ACP script") + AcpClient::spawn( + &crate::testshell::posix_shell_command(), + &["-c".to_string(), script], + &[], + false, + ) + .await + .expect("spawn switch ACP script") } /// F3: an applied switch must cache `models` from the POST-switch snapshot, diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index a98c21dd162..82b975cd479 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -782,6 +782,22 @@ impl EventQueue { self.queues.len() } + /// Whether `scope` still has work that can be reconstructed into a batch. + /// + /// Busy-owner hold timestamps are derived from pending queue state. Queue + /// cap eviction can retire a scope without going through a pool cleanup + /// path, so the dispatch loop uses this seam to prune orphaned holds before + /// scheduling their deadline wakeups. + pub(crate) fn has_pending_scope(&self, scope: &SessionScope) -> bool { + self.queues + .get(scope) + .is_some_and(|queue| !queue.is_empty()) + || self + .cancelled_batches + .get(scope) + .is_some_and(|events| !events.is_empty()) + } + /// Number of queued events for a specific scope (or channel, treated as its /// conversation scope). Test-only. #[cfg(test)] diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index cffdae3b902..23952c9eaa8 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -753,6 +753,8 @@ enum RelayMessage { Auth { challenge: String, }, + /// Task-list cache advisory; the agent harness has no task-list cache. + TasksSyncRequired, } /// Subscription ID for the global membership notification subscription. @@ -1260,10 +1262,8 @@ struct BgState { /// On reconnect resubscribe, `since` = min(last_seen, channel_dropped_since). /// Cleared per-channel after a successful resubscribe. channel_dropped_since: HashMap, - /// Set by the backpressure handler when the event channel is full. - /// The main loop checks this flag and triggers a proactive resubscribe - /// (without waiting for a disconnect) so dropped events are replayed. - proactive_resubscribe_needed: bool, + /// Rate/fairness bookkeeping only; replay cursors retain baseline semantics. + recovery: recovery::RecoverySchedule, /// Unix timestamp captured just before the relay connection was established. /// Used as the floor `since` for membership notification replay so events /// predating this session are never re-delivered. @@ -1337,7 +1337,7 @@ impl BgState { membership_sub_active: false, observer_control_sub_active: false, channel_dropped_since: HashMap::new(), - proactive_resubscribe_needed: false, + recovery: recovery::RecoverySchedule::default(), startup_watermark: None, subscribe_since: HashMap::new(), rate_limit_gate: None, @@ -1398,6 +1398,9 @@ impl BgState { /// Prevents stale replay on re-subscribe and avoids unbounded state growth /// for channels that are removed and never re-added. fn clear_channel_state(&mut self, channel_id: &Uuid) { + self.recovery + .last_attempt + .remove(&channel_sub_id(*channel_id)); self.last_seen.remove(channel_id); self.subscribe_since.remove(channel_id); self.channel_dropped_since.remove(channel_id); @@ -1926,82 +1929,6 @@ async fn run_background_task( let mut drain_pacing_next: Option = None; loop { - if state.proactive_resubscribe_needed { - state.proactive_resubscribe_needed = false; - info!("proactive resubscribe triggered by backpressure event loss"); - // Proactive resubscribe runs on the EXISTING socket — do NOT clear the - // rate-limit gate or pending queues. - match resubscribe_after_reconnect( - &mut ws, - &mut cmd_rx, - &mut state, - &agent_pubkey_hex, - false, // existing socket — preserve gate state - ) - .await - { - ResubscribeResult::Ok => {} - ResubscribeResult::Shutdown => return, - ResubscribeResult::RetryConnection => { - warn!("proactive resubscribe had failures — triggering reconnect"); - let _ = event_tx.try_send(None); - match try_autonomous_reconnect( - &mut ws, - &mut cmd_rx, - &mut state, - &keys, - &relay_url, - &agent_pubkey_hex, - &event_tx, - &observer_control_tx, - auth_tag.as_ref(), - ) - .await - { - ReconnectOutcome::Ok => { - if matches!( - drain_post_reconnect( - &mut ws, - &mut cmd_rx, - &mut state, - &agent_pubkey_hex - ) - .await, - ReconnectOutcome::Shutdown - ) { - return; - } - } - ReconnectOutcome::Shutdown => return, - ReconnectOutcome::Failed => { - if matches!( - wait_for_reconnect( - &mut ws, - &mut cmd_rx, - &mut state, - &keys, - &relay_url, - &agent_pubkey_hex, - &event_tx, - &observer_control_tx, - true, - auth_tag.as_ref(), - ) - .await, - ReconnectOutcome::Shutdown - ) { - return; - } - } - } - ping_sent = false; - last_pong = Instant::now(); - connected_since = Instant::now(); - stable_logged = false; - } - } - } - // Drain pending subs, one REQ per pacing tick within the relay's // admission window. let drain_window_open = drain_pacing_next.is_none_or(|t| tokio::time::Instant::now() >= t); @@ -2082,7 +2009,11 @@ async fn run_background_task( } } + let recovery_at = recovery::ready_at(&mut state); tokio::select! { + _ = recovery::ready(&event_tx, recovery_at) => { + recovery::recover_one(&mut ws, &mut state, &event_tx, &agent_pubkey_hex).await; + } raw = ws.next() => { // Determine if the socket is lost. let socket_lost = match raw { @@ -2458,12 +2389,10 @@ async fn handle_ws_message( // replay starts early enough to re-deliver it. state.membership_dropped_since = Some(state.membership_dropped_since.map_or(ts, |d| d.min(ts))); - // Proactively trigger resubscribe without waiting for a disconnect. - state.proactive_resubscribe_needed = true; warn!( channel_id = %channel_uuid, ts, - "membership notification dropped (backpressure) — proactive resubscribe queued" + "membership notification dropped (backpressure) — targeted recovery pending" ); } Err(mpsc::error::TrySendError::Closed(_)) => return false, @@ -2500,12 +2429,10 @@ async fn handle_ws_message( .entry(channel_id) .and_modify(|d| *d = (*d).min(ts)) .or_insert(ts); - // Proactively trigger resubscribe without waiting for a disconnect. - state.proactive_resubscribe_needed = true; warn!( channel_id = %channel_id, ts, - "event channel full — dropping event for channel {channel_id} — proactive resubscribe queued" + "event channel full — dropping event for channel {channel_id} — targeted recovery pending" ); } Err(mpsc::error::TrySendError::Closed(_)) => { @@ -2677,6 +2604,7 @@ async fn handle_ws_message( warn!("CLOSED for unknown subscription {subscription_id} — ignoring"); } } + RelayMessage::TasksSyncRequired => {} RelayMessage::Auth { challenge } => { // AUTH send failure must trigger reconnect. debug!("received mid-session AUTH challenge — re-authenticating"); @@ -2740,8 +2668,6 @@ async fn handle_ws_message( ); } } - state.acknowledge_observer_frame(&event_id); - debug!("OK for event {event_id}: accepted={accepted} message={message}"); } } true @@ -2810,6 +2736,8 @@ async fn process_handshake_buffer( } => serde_json::to_string(&json!(["OK", event_id, accepted, message])).ok(), // AUTH in the buffer is stale — skip it. RelayMessage::Auth { .. } => None, + // The harness does not consume task-list cache invalidations. + RelayMessage::TasksSyncRequired => None, }; if let Some(text) = text { let should_continue = handle_ws_message( @@ -3996,6 +3924,18 @@ pub(crate) fn parse_relay_message(text: &str) -> Result { + let valid_scope = matches!(arr.get(1), Some(Value::Null)) + || arr + .get(1) + .and_then(Value::as_str) + .and_then(|id| Uuid::parse_str(id).ok()) + .is_some(); + if arr.len() != 2 || !valid_scope { + return Err(RelayError::UnexpectedMessage(text.to_string())); + } + Ok(RelayMessage::TasksSyncRequired) + } other => Err(RelayError::UnexpectedMessage(format!( "unknown message type: {other}" ))), @@ -4372,6 +4312,15 @@ async fn wait_for_any_ok( } } +mod recovery; + +#[cfg(test)] +mod recovery_tests; + +#[cfg(test)] +#[path = "relay/task_sync_tests.rs"] +mod task_sync_tests; + #[cfg(test)] mod tests { use super::*; @@ -4899,7 +4848,7 @@ mod tests { .expect("signing should succeed") } - async fn test_ws_pair() -> (WsStream, WebSocketStream) { + pub(super) async fn test_ws_pair() -> (WsStream, WebSocketStream) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind test websocket"); @@ -4916,7 +4865,7 @@ mod tests { (client, server.await.expect("join test websocket server")) } - async fn next_test_frame( + pub(super) async fn next_test_frame( server: &mut WebSocketStream, ) -> serde_json::Value { let message = timeout(Duration::from_secs(1), server.next()) @@ -5159,14 +5108,14 @@ mod tests { )); } - fn test_channel_filter() -> ChannelFilter { + pub(super) fn test_channel_filter() -> ChannelFilter { ChannelFilter { kinds: Some(vec![9]), require_mention: false, } } - fn seed_test_subscription(state: &mut BgState, channel_id: Uuid) { + pub(super) fn seed_test_subscription(state: &mut BgState, channel_id: Uuid) { apply_command_to_state( state, RelayCommand::Subscribe { @@ -6942,33 +6891,100 @@ mod tests { } } - /// The bug this replaced: every `OK` retired its in-flight frame, so a - /// rejected event was dropped from the resend queue exactly as though it - /// had been stored, and the rejection was only visible at `debug!`. - #[test] - fn transient_rejection_keeps_the_frame_queued_for_resend() { - let mut state = BgState::new(); - let keys = Keys::generate(); - let refused = make_observer_frame(&keys); - state.track_observer_in_flight(Box::new(refused.clone())); - - // What the OK handler does for a Retriable disposition: nothing. - assert_eq!( - classify_ok(false, "rate-limited: slow down"), - OkDisposition::Retriable - ); + /// Exercise real socket frames through the production handler and resend + /// writer: a transient refusal retains the signed event until a terminal OK. + #[tokio::test] + async fn transient_rejection_keeps_the_frame_queued_for_resend() { + for (refusal, accepted, terminal) in [ + ("error: database unavailable", true, "stored"), + ("unrecognized refusal", false, "duplicate: already stored"), + ("", false, "invalid: bad signature"), + ] { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let keys = Keys::generate(); + let refused = make_observer_frame(&keys); + let (event_tx, _event_rx) = mpsc::channel(1); + let (control_tx, _control_rx) = mpsc::channel(1); + assert!( + execute_connected_command( + &mut client, + &mut state, + &keys.public_key().to_hex(), + RelayCommand::PublishEvent { + event: Box::new(refused.clone()), + }, + ) + .await + ); + let original = next_test_frame(&mut server).await; + assert_eq!(original, json!(["EVENT", refused])); - state.requeue_observer_in_flight(); - let ids: Vec<_> = state - .gated_observer_pending - .iter() - .map(|event| event.id) - .collect(); - assert_eq!( - ids, - [refused.id], - "a transiently refused event must survive to be resent" - ); + for (is_terminal, accepted, message) in + [(false, false, refusal), (true, accepted, terminal)] + { + server + .send(Message::Text( + json!(["OK", refused.id.to_hex(), accepted, message]) + .to_string() + .into(), + )) + .await + .expect("write actual relay OK frame"); + let frame = timeout(Duration::from_secs(1), client.next()) + .await + .expect("read OK deadline") + .expect("socket stays open") + .expect("valid WebSocket frame"); + assert!( + handle_ws_message( + frame, + &mut client, + &event_tx, + &control_tx, + &mut state, + &keys, + "ws://localhost", + &keys.public_key().to_hex(), + None, + ) + .await + ); + assert!(state.check_rate_gate().is_none()); + let retained: Vec<_> = state + .observer_in_flight + .iter() + .map(|event| event.id) + .collect(); + if is_terminal { + assert!(retained.is_empty(), "terminal OK must retire the frame"); + state.requeue_observer_in_flight(); + assert_eq!( + drain_gated_observer_pending(&mut client, &mut state, 1).await, + 0, + ); + assert!(timeout(Duration::from_millis(20), server.next()) + .await + .is_err()); + } else { + assert_eq!( + retained, + [refused.id], + "{refusal:?} must not acknowledge an uncommitted observer frame" + ); + state.requeue_observer_in_flight(); + assert_eq!( + drain_gated_observer_pending(&mut client, &mut state, 1).await, + 1, + ); + assert_eq!( + next_test_frame(&mut server).await, + original, + "retry must publish the same signed event bytes" + ); + } + } + } } /// The parked-frame queue is bounded: overflow evicts the oldest frame and diff --git a/crates/buzz-acp/src/relay/recovery.rs b/crates/buzz-acp/src/relay/recovery.rs new file mode 100644 index 00000000000..62da119a4be --- /dev/null +++ b/crates/buzz-acp/src/relay/recovery.rs @@ -0,0 +1,132 @@ +//! Overflow recovery is an attempted replay, not an EOSE/consumer receipt. +//! Keep the existing IDs and cursor retirement rules; bound when work is sent. +use super::*; + +pub(super) const RECOVERY_INTERVAL: Duration = Duration::from_secs(5); + +#[derive(Default)] +pub(super) struct RecoverySchedule { + next_attempt: Option, + pub(super) last_attempt: HashMap, +} + +/// Attempt at most one affected subscription, with space for replay to arrive. +/// Failed writes retain the loss cursor and are paced too. No EOSE is interpreted +/// as completion: overlapping requests keep their existing stable wire IDs. +pub(super) async fn recover_one( + ws: &mut WsStream, + state: &mut BgState, + event_tx: &mpsc::Sender>, + agent_pubkey_hex: &str, +) { + let now = tokio::time::Instant::now(); + if event_tx.is_closed() + || event_tx.capacity() < event_tx.max_capacity().div_ceil(2) + || state.recovery.next_attempt.is_some_and(|next| now < next) + || state.check_rate_gate().is_some() + { + return; + } + + let channel = next_channel(state); + let Some(channel) = channel else { return }; + let sub = channel.map_or_else(|| MEMBERSHIP_NOTIF_SUB_ID.to_owned(), channel_sub_id); + state.recovery.last_attempt.insert(sub.clone(), now); + info!(subscription = sub, "attempting targeted overflow replay"); + + if let Some(ch) = channel { + if let Some(filter) = state.active_filters.get(&ch).cloned() { + let since = state.channel_since(&ch); + if send_subscribe(ws, state, ch, agent_pubkey_hex, since, &filter).await { + // Baseline retirement point: REQ write, NOT proven delivery. + // New overflow after this attempt creates another pending cursor. + state.channel_dropped_since.remove(&ch); + } + } + } else { + let since = match (state.membership_dropped_since, state.membership_last_seen) { + (Some(d), Some(l)) => Some(d.min(l)), + (Some(d), None) => Some(d), + (None, Some(l)) => Some(l), + (None, None) => state.startup_watermark, + }; + if send_membership_subscribe(ws, agent_pubkey_hex, since).await { + state.membership_dropped_since = None; + } + } + // Pace from the end of a potentially backpressured write. No catch-up burst. + // The existing bounded write timeout and read/ping owner detect socket loss. + state.recovery.next_attempt = Some(tokio::time::Instant::now() + RECOVERY_INTERVAL); +} + +/// No timer or capacity waiter when another authority owns all pending loss. +/// Only actual attempts advance the cooldown; closed gates wake at expiry. +pub(super) fn ready_at(state: &mut BgState) -> Option { + next_channel(state)?; + Some( + state + .recovery + .next_attempt + .unwrap_or_else(tokio::time::Instant::now) + .max( + state + .check_rate_gate() + .unwrap_or_else(tokio::time::Instant::now), + ), + ) +} + +/// Select-local readiness, not a send or a reservation carried across reads. +/// The socket task is the sole producer. `select!` drops this future (including +/// partial permits) BEFORE handling another frame/command, so live try_send +/// never competes with a recovery reservation. Receives only add capacity. +/// Keep this future inside select!: awaiting it alone would block the reader; +/// persisting it across iterations would steal capacity from live delivery. +pub(super) async fn ready( + event_tx: &mpsc::Sender>, + at: Option, +) { + if let Some(at) = at { + if tokio::time::Instant::now() < at { + tokio::time::sleep_until(at).await; + } + // Use the channel's own race-free capacity wake, not periodic samples. + // Return ALL permits before recover_one rechecks capacity and intent. + if let Ok(permits) = event_tx + .reserve_many(event_tx.max_capacity().div_ceil(2)) + .await + { + drop(permits); + return; + } + } + // No loss or a closed receiver: no immediate-ready/error wake loop. + std::future::pending::<()>().await; +} + +fn next_channel(state: &BgState) -> Option> { + // One record per active intent, not per loss or per request generation. + // Least recently attempted prevents a repeatedly overflowing channel from + // starving other channels or membership. Missing filters fail closed. + state + .channel_dropped_since + .keys() + .filter(|ch| { + state.active_subscriptions.contains_key(ch) + && state.active_filters.contains_key(ch) + && !state.rate_limited_pending.contains_key(ch) + && !state.resubscribe_retry.contains(ch) + }) + .copied() + .map(Some) + .chain( + (state.membership_sub_active + && state.membership_dropped_since.is_some() + && !state.membership_resub_needed) + .then_some(None), + ) + .min_by_key(|ch| { + let sub = ch.map_or_else(|| MEMBERSHIP_NOTIF_SUB_ID.to_owned(), channel_sub_id); + (state.recovery.last_attempt.get(&sub).copied(), sub) + }) +} diff --git a/crates/buzz-acp/src/relay/recovery_tests.rs b/crates/buzz-acp/src/relay/recovery_tests.rs new file mode 100644 index 00000000000..6a8c3d01a61 --- /dev/null +++ b/crates/buzz-acp/src/relay/recovery_tests.rs @@ -0,0 +1,411 @@ +//! Bounded synthetic WebSocket fixtures; no relay service, proxy or real agent. +use super::tests::{next_test_frame, seed_test_subscription, test_channel_filter, test_ws_pair}; +use super::*; + +fn fixture_event(channel: Uuid, n: u64, kind: u16) -> Event { + let keys = + Keys::parse("0000000000000000000000000000000000000000000000000000000000000001").unwrap(); + EventBuilder::new(Kind::Custom(kind), format!("synthetic-{n}")) + .tags([Tag::parse(["h", &channel.to_string()]).unwrap()]) + .custom_created_at(nostr::Timestamp::from(1_000 + n)) + .sign_with_keys(&keys) + .unwrap() +} + +async fn dispatch( + client: &mut WsStream, + state: &mut BgState, + tx: &mpsc::Sender>, + frame: Value, +) { + let (control_tx, _control_rx) = mpsc::channel(1); + assert!( + handle_ws_message( + Message::Text(frame.to_string().into()), + client, + tx, + &control_tx, + state, + &Keys::generate(), + "ws://127.0.0.1:1", + "synthetic-agent", + None, + ) + .await + ); +} + +#[tokio::test] +async fn repeated_overflow_recovers_only_affected_channel_after_capacity() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let channels: Vec<_> = (0..18).map(|_| Uuid::new_v4()).collect(); + for ch in &channels { + seed_test_subscription(&mut state, *ch); + } + let ch = channels[0]; + let sub = channel_sub_id(ch); + let (tx, mut rx) = mpsc::channel(256); + // Relay history is newest-first; the oldest dropped event must survive + // a watermark already advanced by much newer successfully-enqueued events. + let events: Vec<_> = (0..320).rev().map(|n| fixture_event(ch, n, 9)).collect(); + for event in &events { + dispatch(&mut client, &mut state, &tx, json!(["EVENT", sub, event])).await; + recovery::recover_one(&mut client, &mut state, &tx, "synthetic-agent").await; + } + assert_eq!(rx.len(), 256); + assert_eq!(state.channel_dropped_since[&ch], 1_000); + assert!(timeout(Duration::from_millis(30), server.next()) + .await + .is_err()); + for event in &events[..256] { + assert_eq!(rx.recv().await.unwrap().unwrap().event.id, event.id); + } + recovery::recover_one(&mut client, &mut state, &tx, "synthetic-agent").await; + let req = next_test_frame(&mut server).await; + assert_eq!(req[0], "REQ"); + assert_eq!(req[1], sub); + assert_eq!(req[2]["#h"], json!([ch.to_string()])); + assert_eq!(req[2]["kinds"], json!([9])); + assert_eq!(req[2]["since"], 995); + // Concurrent timer ticks / duplicate arrivals cannot replace the replay. + for _ in 0..40 { + recovery::recover_one(&mut client, &mut state, &tx, "synthetic-agent").await; + } + assert!(timeout(Duration::from_millis(30), server.next()) + .await + .is_err()); + for event in &events { + dispatch(&mut client, &mut state, &tx, json!(["EVENT", sub, event])).await; + } + dispatch(&mut client, &mut state, &tx, json!(["EOSE", sub])).await; + assert_eq!(rx.len(), 64, "delivered IDs must remain deduplicated"); + for event in &events[256..] { + assert_eq!(rx.recv().await.unwrap().unwrap().event.id, event.id); + } + assert_eq!(state.channel_since(&ch), Some(1_319)); + recovery::recover_one(&mut client, &mut state, &tx, "synthetic-agent").await; + assert!(timeout(Duration::from_millis(30), server.next()) + .await + .is_err()); + let live = fixture_event(ch, 400, 9); + dispatch(&mut client, &mut state, &tx, json!(["EVENT", sub, live])).await; + assert_eq!(rx.recv().await.unwrap().unwrap().event.id, live.id); + println!("18 subscriptions; 320 newest-first arrivals; 64 losses coalesced; 0 REQ while full; 1 targeted REQ; 320 unique deliveries + live"); +} + +#[tokio::test] +async fn socket_owner_services_ping_shutdown_and_coalesces_overflow_ticks() { + let (client, mut server) = test_ws_pair().await; + let (tx, mut rx) = mpsc::channel(1); + let (control_tx, _control_rx) = mpsc::channel(1); + let (cmd_tx, cmd_rx) = mpsc::channel(64); + let task = tokio::spawn(run_background_task( + client, + VecDeque::new(), + tx, + control_tx, + cmd_rx, + Keys::generate(), + "ws://127.0.0.1:1".into(), + "synthetic-agent".into(), + None, + )); + let channels: Vec<_> = (0..18).map(|_| Uuid::new_v4()).collect(); + for ch in &channels { + cmd_tx + .send(RelayCommand::Subscribe { + channel_id: *ch, + filter: test_channel_filter(), + replay_since: Some(1_000), + }) + .await + .unwrap(); + } + let mut subscriptions = 0; + while subscriptions < 18 { + let frame = timeout(Duration::from_secs(2), server.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + match frame { + Message::Text(text) => { + let req: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(req[0], "REQ"); + server + .send(Message::Text(json!(["EOSE", req[1]]).to_string().into())) + .await + .unwrap(); + subscriptions += 1; + } + Message::Ping(payload) => server.send(Message::Pong(payload)).await.unwrap(), + other => panic!("unexpected {other:?}"), + } + } + let sub = channel_sub_id(channels[0]); + for n in 0..40 { + server + .send(Message::Text( + json!(["EVENT", sub, fixture_event(channels[0], n, 9)]) + .to_string() + .into(), + )) + .await + .unwrap(); + } + server.send(Message::Ping(vec![42].into())).await.unwrap(); + timeout(Duration::from_secs(2), async { + loop { + match server.next().await.unwrap().unwrap() { + Message::Ping(payload) => server.send(Message::Pong(payload)).await.unwrap(), + Message::Pong(payload) => { + assert_eq!(payload.as_ref(), &[42]); + break; + } + other => panic!("no immediate all-channel recovery before ping: {other:?}"), + } + } + }) + .await + .unwrap(); + // Wait across a recovery tick with the consumer still full. + assert!(socket_frame(&mut server, Duration::from_millis(5_100)) + .await + .is_none()); + rx.recv().await.unwrap().unwrap(); + let frame = timeout(Duration::from_secs(6), server.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let req: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(req[1], sub); + assert_eq!(req[2]["since"], 996); + assert!(timeout(Duration::from_millis(100), server.next()) + .await + .is_err()); + // Sustained lag: each replay is followed by another burst, without EOSE. + // Recovery must stay paced, not permanently stall or sweep healthy channels. + for round in 1..=3 { + let started = tokio::time::Instant::now(); + for n in round * 40..(round + 1) * 40 { + server + .send(Message::Text( + json!(["EVENT", sub, fixture_event(channels[0], n, 9)]) + .to_string() + .into(), + )) + .await + .unwrap(); + } + server.send(Message::Ping(vec![43].into())).await.unwrap(); + let pong = timeout(Duration::from_secs(1), server.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!( + matches!(pong, Message::Pong(_)), + "recovery preempted ping: {pong:?}" + ); + rx.recv().await.unwrap().unwrap(); + let frame = timeout(Duration::from_secs(6), server.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let req: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(req[1], sub, "healthy channels must not be swept"); + assert!( + started.elapsed() >= Duration::from_secs(4), + "unpaced repeat" + ); + } + cmd_tx.send(RelayCommand::Shutdown).await.unwrap(); + timeout(Duration::from_secs(1), task) + .await + .unwrap() + .unwrap(); + println!("socket-owner seam: 18 live REQs, 39 coalesced losses, PONG while full, zero recovery across full-capacity timer tick, four paced targeted REQs over sustained lag without EOSE, responsive shutdown"); +} + +#[tokio::test] +async fn recovery_is_fair_and_paced_even_with_new_loss_and_stale_eose() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let channels = [Uuid::new_v4(), Uuid::new_v4()]; + for ch in channels { + seed_test_subscription(&mut state, ch); + state.channel_dropped_since.insert(ch, 600); + } + state.membership_sub_active = true; + state.membership_dropped_since = Some(500); + let (tx, _rx) = mpsc::channel(1); + let mut visited = HashSet::new(); + for round in 0..9 { + timeout( + Duration::from_millis(100), + recovery::ready(&tx, recovery::ready_at(&mut state)), + ) + .await + .unwrap(); + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + let req = next_test_frame(&mut server).await; + let sub = req[1].as_str().unwrap(); + if round < 3 { + assert!(visited.insert(sub.to_owned()), "starved intent"); + } + if sub == MEMBERSHIP_NOTIF_SUB_ID { + assert_eq!(req[2]["since"], 495); + state.membership_dropped_since = Some(500); + } else { + let ch = channel_id_from_sub_id(sub).unwrap(); + assert_eq!(req[2]["since"], 595); + state.channel_dropped_since.insert(ch, 600); + } + // Neither stale nor current EOSE creates completion state or erases loss. + dispatch(&mut client, &mut state, &tx, json!(["EOSE", sub])).await; + for _ in 0..30 { + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + } + assert!(timeout(Duration::from_millis(1), server.next()) + .await + .is_err()); + advance_clock(recovery::RECOVERY_INTERVAL).await; + } + for ch in channels { + state.active_subscriptions.remove(&ch); + state.clear_channel_state(&ch); + assert!(!state + .recovery + .last_attempt + .contains_key(&channel_sub_id(ch))); + } + assert_eq!(state.recovery.last_attempt.len(), 1); +} + +#[tokio::test] +async fn gate_headroom_failed_writes_and_reconnect_preserve_pending_attempts() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let ch = Uuid::new_v4(); + seed_test_subscription(&mut state, ch); + state.channel_dropped_since.insert(ch, 700); + let (tx, mut rx) = mpsc::channel(4); + for _ in 0..3 { + tx.try_send(None).unwrap(); + } + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + assert!(state.recovery.last_attempt.is_empty()); + rx.recv().await; + state.rate_limit_gate = Some(tokio::time::Instant::now() + Duration::from_secs(10)); + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + assert!(state.recovery.last_attempt.is_empty()); + advance_clock(Duration::from_secs(10)).await; + // Close locally, so the actual production writer fails deterministically. + client.close(None).await.unwrap(); + server.next().await; + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + assert_eq!(state.channel_dropped_since[&ch], 700); + let attempted = state.recovery.last_attempt.clone(); + for _ in 0..30 { + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + } + assert_eq!(state.recovery.last_attempt, attempted); + advance_clock(recovery::RECOVERY_INTERVAL).await; + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + assert_ne!( + state.recovery.last_attempt, attempted, + "failed write must be retried" + ); + assert_eq!(state.channel_dropped_since[&ch], 700); + + let (mut client, mut server) = test_ws_pair().await; + let (_cmd_tx, mut cmd_rx) = mpsc::channel(1); + assert!(matches!( + resubscribe_after_reconnect(&mut client, &mut cmd_rx, &mut state, "agent", true,).await, + ResubscribeResult::Ok + )); + let req = next_test_frame(&mut server).await; + assert_eq!(req[1], channel_sub_id(ch)); + assert_eq!(req[2]["since"], 695); + assert!(!state.channel_dropped_since.contains_key(&ch)); + // This is deliberately the baseline write-retirement contract, not a receipt. +} + +async fn advance_clock(duration: Duration) { + tokio::time::pause(); + tokio::time::advance(duration).await; + tokio::time::resume(); +} + +#[tokio::test] +async fn blocked_recovery_write_is_bounded_and_retains_loss() { + let (mut client, _stalled_server) = test_ws_pair().await; + // A single large send can finish immediately on Windows loopback even if + // the peer never reads. Establish actual backpressure first, with bounded + // memory and attempts, rather than assuming an OS socket-buffer capacity. + let mut blocked = false; + for _ in 0..32 { + match timeout( + Duration::from_millis(100), + client.send(Message::Binary(vec![0; 1024 * 1024].into())), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(error)) => panic!("backpressure fixture write failed: {error}"), + Err(_) => { + blocked = true; + break; + } + } + } + assert!(blocked, "fixture did not establish socket backpressure"); + + let mut state = BgState::new(); + let ch = Uuid::new_v4(); + seed_test_subscription(&mut state, ch); + state.channel_dropped_since.insert(ch, 700); + let (tx, _rx) = mpsc::channel(1); + let started = tokio::time::Instant::now(); + timeout( + Duration::from_secs(15), + recovery::recover_one(&mut client, &mut state, &tx, "agent"), + ) + .await + .unwrap(); + assert!(started.elapsed() >= Duration::from_secs(WS_SEND_TIMEOUT_SECS)); + assert_eq!(state.channel_dropped_since[&ch], 700); + let attempted = state.recovery.last_attempt.clone(); + timeout( + Duration::from_secs(1), + recovery::recover_one(&mut client, &mut state, &tx, "agent"), + ) + .await + .expect("paced recovery must not attempt another blocked write"); + assert_eq!(state.recovery.last_attempt, attempted); +} + +// Keep the fixture responsive to independent client keepalives while checking +// recovery traffic. Wall-clock scheduling may deliver the initial ping late. +async fn socket_frame( + server: &mut WebSocketStream, + duration: Duration, +) -> Option { + let deadline = tokio::time::Instant::now() + duration; + loop { + match tokio::time::timeout_at(deadline, server.next()).await { + Err(_) => return None, + Ok(Some(Ok(Message::Ping(payload)))) => { + server.send(Message::Pong(payload)).await.unwrap(); + } + Ok(Some(Ok(frame))) => return Some(frame), + other => panic!("unexpected socket state: {other:?}"), + } + } +} + +#[path = "recovery_wake_tests.rs"] +mod wake; diff --git a/crates/buzz-acp/src/relay/recovery_wake_tests.rs b/crates/buzz-acp/src/relay/recovery_wake_tests.rs new file mode 100644 index 00000000000..de27c7d06c9 --- /dev/null +++ b/crates/buzz-acp/src/relay/recovery_wake_tests.rs @@ -0,0 +1,507 @@ +//! Capacity-wake boundaries and the independently reproduced R1 schedule. +use super::*; + +// Reviewer-authored, exact-source comparison of recurring headroom at the socket owner. +// A small periodically refilled queue is empty for most of each five-second period. +async fn review_count_until( + server: &mut WebSocketStream, + deadline: tokio::time::Instant, +) -> usize { + let mut count = 0; + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + match socket_frame(server, remaining).await { + None => break, + Some(Message::Text(text)) => { + let frame: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(frame[0], "REQ"); + count += 1; + } + other => panic!("unexpected {other:?}"), + } + } + count +} + +async fn review_barrier(server: &mut WebSocketStream) -> usize { + server.send(Message::Ping(vec![77].into())).await.unwrap(); + let mut count = 0; + loop { + match socket_frame(server, Duration::from_secs(2)).await.unwrap() { + Message::Pong(payload) => { + assert_eq!(payload.as_ref(), &[77]); + return count; + } + Message::Text(text) => { + let frame: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(frame[0], "REQ"); + count += 1; + } + other => panic!("unexpected {other:?}"), + } + } +} + +#[tokio::test] +async fn review_recurring_headroom_between_ticks_gets_an_attempt() { + let (client, mut server) = test_ws_pair().await; + let (tx, mut rx) = mpsc::channel(1); + let (control_tx, _control_rx) = mpsc::channel(1); + let (cmd_tx, cmd_rx) = mpsc::channel(8); + let start = tokio::time::Instant::now(); + let task = tokio::spawn(run_background_task( + client, + VecDeque::new(), + tx, + control_tx, + cmd_rx, + Keys::generate(), + "ws://127.0.0.1:1".into(), + "agent".into(), + None, + )); + let ch = Uuid::new_v4(); + let sub = channel_sub_id(ch); + cmd_tx + .send(RelayCommand::Subscribe { + channel_id: ch, + filter: test_channel_filter(), + replay_since: Some(1000), + }) + .await + .unwrap(); + let frame = socket_frame(&mut server, Duration::from_secs(2)) + .await + .unwrap(); + let req: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(req[1], sub); + let lost = fixture_event(ch, 1, 9); + for event in [fixture_event(ch, 0, 9), lost.clone()] { + server + .send(Message::Text( + json!(["EVENT", sub, event]).to_string().into(), + )) + .await + .unwrap(); + } + let mut attempts = review_barrier(&mut server).await; + assert_eq!(rx.len(), 1); + rx.recv().await.unwrap().unwrap(); + for round in 1..=4 { + // Headroom until 1s before the tick; then only one live arrival, no new + // overflow. The queue stays full across the tick and drains 0.5s later. + attempts += review_count_until( + &mut server, + start + Duration::from_millis(round * 5000 - 1000), + ) + .await; + assert_eq!(rx.len(), 0); + server + .send(Message::Text( + json!(["EVENT", sub, fixture_event(ch, 10 + round, 9)]) + .to_string() + .into(), + )) + .await + .unwrap(); + attempts += review_barrier(&mut server).await; + assert_eq!(rx.len(), 1); + attempts += review_count_until( + &mut server, + start + Duration::from_millis(round * 5000 + 500), + ) + .await; + rx.recv().await.unwrap().unwrap(); + } + println!("REVIEW periodic consumer: 4 full-at-tick windows, empty >=3.5s each period, recovery_requests={attempts}"); + assert_eq!( + attempts, 1, + "recurring headroom must not strand the first attempt" + ); + // Return the missing event using the actual requested stable subscription. + server + .send(Message::Text( + json!(["EVENT", sub, lost]).to_string().into(), + )) + .await + .unwrap(); + assert_eq!(review_barrier(&mut server).await, 0); + assert_eq!(rx.recv().await.unwrap().unwrap().event.id, lost.id); + let after = review_count_until(&mut server, start + Duration::from_millis(25_500)).await; + assert_eq!( + after, 0, + "no timer churn or extra requests after successful write" + ); + println!( + "REVIEW continuous-headroom control: additional_requests={after}; missing event delivered" + ); + cmd_tx.send(RelayCommand::Shutdown).await.unwrap(); + timeout(Duration::from_secs(2), task) + .await + .unwrap() + .unwrap(); +} + +/// Same real socket owner as production, with no control of its internal state. +struct Owner { + server: WebSocketStream, + rx: mpsc::Receiver>, + cmd: mpsc::Sender, + task: tokio::task::JoinHandle<()>, + ch: Uuid, +} + +impl Owner { + async fn new(capacity: usize) -> Self { + let (client, server) = test_ws_pair().await; + let (tx, rx) = mpsc::channel(capacity); + let (control_tx, _control_rx) = mpsc::channel(1); + let (cmd, cmd_rx) = mpsc::channel(8); + let task = tokio::spawn(run_background_task( + client, + VecDeque::new(), + tx, + control_tx, + cmd_rx, + Keys::generate(), + "ws://127.0.0.1:1".into(), + "agent".into(), + None, + )); + let mut owner = Self { + server, + rx, + cmd, + task, + ch: Uuid::new_v4(), + }; + owner.subscribe().await; + owner + } + + async fn subscribe(&mut self) { + self.cmd + .send(RelayCommand::Subscribe { + channel_id: self.ch, + filter: test_channel_filter(), + replay_since: Some(1000), + }) + .await + .unwrap(); + let req = self.request(Duration::from_secs(2)).await; + assert_eq!(req[1], channel_sub_id(self.ch)); + } + + async fn event(&mut self, n: u64) { + self.server + .send(Message::Text( + json!([ + "EVENT", + channel_sub_id(self.ch), + fixture_event(self.ch, n, 9) + ]) + .to_string() + .into(), + )) + .await + .unwrap(); + } + + async fn request(&mut self, duration: Duration) -> Value { + let frame = socket_frame(&mut self.server, duration) + .await + .expect("recovery not woken"); + let req: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(req[0], "REQ"); + req + } + + async fn shutdown(self) { + self.cmd.send(RelayCommand::Shutdown).await.unwrap(); + timeout(Duration::from_secs(1), self.task) + .await + .unwrap() + .unwrap(); + } +} + +#[tokio::test] +async fn capacity_flapping_cannot_storm_or_delay_an_allowed_attempt() { + let mut owner = Owner::new(1).await; + owner.event(0).await; + owner.event(1).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.rx.recv().await.unwrap(); + let first = owner.request(Duration::from_millis(500)).await; + let first_at = tokio::time::Instant::now(); + assert_eq!(first[2]["since"], 996); + // New loss, then rapid full/empty transitions during the attempt cooldown. + // No socket activity or capacity transition may reset or bypass that bound. + for n in 1..=20 { + owner.event(n * 2).await; + owner.event(n * 2 + 1).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.rx.recv().await.unwrap(); + assert!(socket_frame(&mut owner.server, Duration::from_millis(10)) + .await + .is_none()); + } + let req = owner.request(Duration::from_secs(6)).await; + assert_eq!(req[1], channel_sub_id(owner.ch)); + // Arrival timestamps approximate send completion; leave tolerance for TCP. + assert!(first_at.elapsed() >= Duration::from_millis(4_900)); + assert!(first_at.elapsed() < Duration::from_secs(6)); + assert_eq!(req[2]["since"], 998); + assert!(socket_frame(&mut owner.server, Duration::from_millis(100)) + .await + .is_none()); + owner.shutdown().await; +} + +#[tokio::test] +async fn partial_capacity_wait_does_not_steal_live_slots_and_cancels_on_unsubscribe() { + let mut owner = Owner::new(5).await; // odd capacity: threshold rounds UP to 3 + for n in 0..6 { + owner.event(n).await; + } + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.rx.recv().await.unwrap(); // partial reservation, insufficient for replay + assert!(socket_frame(&mut owner.server, Duration::from_millis(30)) + .await + .is_none()); + owner.event(6).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + assert_eq!( + owner.rx.len(), + 5, + "select must release partial permits BEFORE try_send" + ); + for _ in 0..2 { + owner.rx.recv().await.unwrap(); + } + assert!(socket_frame(&mut owner.server, Duration::from_millis(30)) + .await + .is_none()); + owner.rx.recv().await.unwrap(); // exactly three slots free, prompt capacity wake + let req = owner.request(Duration::from_millis(500)).await; + assert_eq!(req[2]["since"], 1000); + assert_eq!( + owner + .rx + .recv() + .await + .unwrap() + .unwrap() + .event + .created_at + .as_secs(), + 1004 + ); + assert_eq!( + owner + .rx + .recv() + .await + .unwrap() + .unwrap() + .event + .created_at + .as_secs(), + 1006 + ); + + // Wait out cooldown then create another pending loss and partial reservation. + tokio::time::sleep(recovery::RECOVERY_INTERVAL).await; + for n in 7..13 { + owner.event(n).await; + } + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.rx.recv().await.unwrap(); + assert!(socket_frame(&mut owner.server, Duration::from_millis(30)) + .await + .is_none()); + owner + .cmd + .send(RelayCommand::Unsubscribe { + channel_id: owner.ch, + }) + .await + .unwrap(); + let close = socket_frame(&mut owner.server, Duration::from_secs(1)) + .await + .unwrap(); + let close: Value = serde_json::from_str(close.to_text().unwrap()).unwrap(); + assert_eq!(close[0], "CLOSE"); + while owner.rx.try_recv().is_ok() {} + assert!(socket_frame(&mut owner.server, Duration::from_millis(100)) + .await + .is_none()); + owner.subscribe().await; + assert!(socket_frame(&mut owner.server, Duration::from_millis(100)) + .await + .is_none()); + // A re-added intent can record and recover fresh loss immediately. + for n in 20..26 { + owner.event(n).await; + } + assert_eq!(review_barrier(&mut owner.server).await, 0); + while owner.rx.try_recv().is_ok() {} + let req = owner.request(Duration::from_millis(500)).await; + assert_eq!(req[2]["since"], 1020); + owner.shutdown().await; +} + +#[tokio::test] +async fn shutdown_and_transport_loss_cancel_a_capacity_wait() { + let mut owner = Owner::new(1).await; + owner.event(0).await; + owner.event(1).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + // Full queue cannot block commands or processing an actual socket close. + owner.server.close(None).await.unwrap(); + owner.shutdown().await; + let mut owner = Owner::new(1).await; + owner.event(0).await; + owner.event(1).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.shutdown().await; +} + +#[tokio::test] +async fn readiness_gate_ownership_and_attempt_deadlines_are_not_polling_ticks() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let ch = Uuid::new_v4(); + seed_test_subscription(&mut state, ch); + let (tx, mut rx) = mpsc::channel(4); + assert!(recovery::ready_at(&mut state).is_none()); + state.channel_dropped_since.insert(ch, 700); + state + .rate_limited_pending + .insert(ch, tokio::time::Instant::now()); + assert!(recovery::ready_at(&mut state).is_none()); + state.rate_limited_pending.clear(); + state.resubscribe_retry.insert(ch); + assert!(recovery::ready_at(&mut state).is_none()); + state.resubscribe_retry.clear(); + state.rate_limit_gate = Some(tokio::time::Instant::now() + Duration::from_millis(80)); + let at = recovery::ready_at(&mut state); + assert_eq!(at, state.rate_limit_gate); + assert!(timeout(Duration::from_millis(20), recovery::ready(&tx, at)) + .await + .is_err()); + timeout(Duration::from_millis(200), recovery::ready(&tx, at)) + .await + .unwrap(); + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + next_test_frame(&mut server).await; + state.channel_dropped_since.insert(ch, 800); + let at = recovery::ready_at(&mut state).unwrap(); + let remaining = at.saturating_duration_since(tokio::time::Instant::now()); + assert!(remaining > Duration::from_millis(4900)); + // Neither new loss nor an intervening gate shorter than cooldown delays it. + state.rate_limit_gate = Some(tokio::time::Instant::now() + Duration::from_millis(10)); + assert_eq!(recovery::ready_at(&mut state), Some(at)); + state.rate_limit_gate = Some(at + Duration::from_secs(1)); + assert_eq!(recovery::ready_at(&mut state), state.rate_limit_gate); + advance_clock(Duration::from_secs(6)).await; + // Cancellation releases partial permits. No hidden reservation survives it. + for _ in 0..3 { + tx.try_send(None).unwrap(); + } + assert!(timeout( + Duration::from_millis(20), + recovery::ready(&tx, recovery::ready_at(&mut state)) + ) + .await + .is_err()); + assert_eq!(tx.capacity(), 1); + rx.recv().await.unwrap(); + timeout( + Duration::from_millis(100), + recovery::ready(&tx, recovery::ready_at(&mut state)), + ) + .await + .unwrap(); + assert_eq!(tx.capacity(), 2); + drop(rx); + assert!(timeout( + Duration::from_millis(20), + recovery::ready(&tx, recovery::ready_at(&mut state)) + ) + .await + .is_err()); +} + +#[tokio::test] +async fn readiness_uses_channel_wakes_without_idle_churn_or_lost_capacity() { + use std::future::Future; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + use std::task::{Context, Wake, Waker}; + #[derive(Default)] + struct Wakes(AtomicUsize); + impl Wake for Wakes { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } + fn wake_by_ref(self: &Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let wakes = Arc::new(Wakes::default()); + let waker = Waker::from(wakes.clone()); + let mut cx = Context::from_waker(&waker); + let (tx, mut rx) = mpsc::channel(4); + for _ in 0..4 { + tx.try_send(None).unwrap(); + } + let mut idle = Box::pin(recovery::ready(&tx, None)); + assert!(idle.as_mut().poll(&mut cx).is_pending()); + rx.recv().await.unwrap(); + rx.recv().await.unwrap(); + assert_eq!( + wakes.0.load(Ordering::SeqCst), + 0, + "no loss: no capacity subscription" + ); + drop(idle); + // Capacity freed before registration cannot be lost; ready on first poll. + let now = Some(tokio::time::Instant::now()); + let mut ready = Box::pin(recovery::ready(&tx, now)); + assert!(ready.as_mut().poll(&mut cx).is_ready()); + assert_eq!(tx.capacity(), 2, "successful readiness returns all permits"); + drop(ready); + for _ in 0..2 { + tx.try_send(None).unwrap(); + } + let mut ready = Box::pin(recovery::ready(&tx, now)); + assert!(ready.as_mut().poll(&mut cx).is_pending()); + assert_eq!(wakes.0.load(Ordering::SeqCst), 0); + rx.recv().await.unwrap(); + assert_eq!( + wakes.0.load(Ordering::SeqCst), + 0, + "below threshold: do not wake" + ); + rx.recv().await.unwrap(); + assert_eq!( + wakes.0.load(Ordering::SeqCst), + 1, + "threshold: channel wakes its waiter" + ); + assert!(ready.as_mut().poll(&mut cx).is_ready()); + assert_eq!(tx.capacity(), 2); + drop(ready); + drop(rx); + let mut closed = Box::pin(recovery::ready(&tx, now)); + let before = wakes.0.load(Ordering::SeqCst); + assert!(closed.as_mut().poll(&mut cx).is_pending()); + assert_eq!( + wakes.0.load(Ordering::SeqCst), + before, + "closed: no self-wake loop" + ); +} diff --git a/crates/buzz-acp/src/relay/task_sync_tests.rs b/crates/buzz-acp/src/relay/task_sync_tests.rs new file mode 100644 index 00000000000..8fede75a2d5 --- /dev/null +++ b/crates/buzz-acp/src/relay/task_sync_tests.rs @@ -0,0 +1,158 @@ +use super::*; + +/// Drive the production connector against a real localhost WebSocket peer. +/// The peer checks the signed NIP-42 response before sending a control frame +/// ahead of the authentication OK, matching the relay's priority queue ordering. +async fn connect_with_control_before_ok( + control: Value, +) -> ( + Result<(WsStream, VecDeque), RelayError>, + WebSocketStream, + Keys, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind local auth peer"); + let relay_url = format!("ws://{}", listener.local_addr().expect("local address")); + let keys = Keys::generate(); + let challenge = "task-sync-auth-challenge"; + let peer = async { + let (stream, _) = listener.accept().await.expect("accept client"); + let mut ws = tokio_tungstenite::accept_async(stream) + .await + .expect("upgrade WebSocket"); + ws.send(Message::Text(json!(["AUTH", challenge]).to_string().into())) + .await + .expect("send challenge"); + let auth = ws + .next() + .await + .expect("client auth frame") + .expect("read auth"); + let auth: Value = + serde_json::from_str(auth.to_text().expect("text auth")).expect("parse auth envelope"); + assert_eq!(auth[0], "AUTH"); + let event: Event = serde_json::from_value(auth[1].clone()).expect("signed auth event"); + buzz_core::verify_event(&event).expect("valid NIP-42 signature"); + assert_eq!(event.kind, Kind::Authentication); + assert_eq!(event.pubkey, keys.public_key()); + let tags = auth[1]["tags"].as_array().expect("auth tags"); + assert!(tags.contains(&json!(["challenge", challenge]))); + assert!(tags.contains(&json!(["relay", relay_url]))); + ws.send(Message::Text(control.to_string().into())) + .await + .expect("send priority control frame"); + ws.send(Message::Text( + json!(["OK", event.id.to_hex(), true, "authenticated"]) + .to_string() + .into(), + )) + .await + .expect("send auth OK"); + ws + }; + let (peer, connected) = timeout(Duration::from_secs(3), async { + tokio::join!(peer, do_connect(&relay_url, &keys, None)) + }) + .await + .expect("real auth handshake finishes within three seconds"); + (connected, peer, keys) +} + +#[tokio::test] +async fn task_advisory_before_auth_ok_does_not_abort_connection_or_dispatch_work() { + let advisory = json!(["BUZZ_TASKS_SYNC_REQUIRED", Uuid::new_v4()]); + let (connected, mut peer, keys) = connect_with_control_before_ok(advisory.clone()).await; + assert!( + connected.is_ok(), + "auth handshake failed: {:?}", + connected.err() + ); + let (mut client, buffer) = connected.expect("authenticated connection"); + assert_eq!(buffer.len(), 1, "the advisory arrived before the auth OK"); + let (event_tx, mut event_rx) = mpsc::channel(4); + let (observer_tx, mut observer_rx) = mpsc::channel(4); + let mut state = BgState::new(); + assert!( + process_handshake_buffer( + &mut client, + buffer, + &event_tx, + &observer_tx, + &mut state, + &keys, + "ws://localhost", + &keys.public_key().to_hex(), + None, + ) + .await + ); + // The same advisory is harmless after authentication as well. + peer.send(Message::Text(advisory.to_string().into())) + .await + .expect("send post-auth advisory"); + let received = timeout(Duration::from_secs(1), client.next()) + .await + .expect("receive advisory promptly") + .expect("connection remains open") + .expect("receive advisory"); + assert!( + handle_ws_message( + received, + &mut client, + &event_tx, + &observer_tx, + &mut state, + &keys, + "ws://localhost", + &keys.public_key().to_hex(), + None, + ) + .await + ); + assert!(matches!( + event_rx.try_recv(), + Err(mpsc::error::TryRecvError::Empty) + )); + assert!(matches!( + observer_rx.try_recv(), + Err(mpsc::error::TryRecvError::Empty) + )); + assert!(state.last_seen.is_empty()); + assert!(state.active_subscriptions.is_empty()); +} + +#[tokio::test] +async fn unknown_control_before_auth_ok_still_rejects_connection() { + let (connected, _peer, _keys) = + connect_with_control_before_ok(json!(["UNRECOGNIZED_CONTROL", Uuid::new_v4()])).await; + assert!( + matches!(connected, Err(RelayError::UnexpectedMessage(ref message)) + if message == "unknown message type: UNRECOGNIZED_CONTROL") + ); +} + +#[test] +fn malformed_task_advisories_remain_protocol_errors() { + for frame in [ + json!(["BUZZ_TASKS_SYNC_REQUIRED"]), + json!(["BUZZ_TASKS_SYNC_REQUIRED", "not-a-channel-id"]), + json!(["BUZZ_TASKS_SYNC_REQUIRED", Uuid::new_v4(), "extra"]), + ] { + assert!( + matches!( + parse_relay_message(&frame.to_string()), + Err(RelayError::UnexpectedMessage(_)) + ), + "malformed advisory was accepted: {frame}" + ); + } +} + +#[test] +fn community_task_advisory_is_a_compatible_control_frame() { + assert!(matches!( + parse_relay_message(r#"["BUZZ_TASKS_SYNC_REQUIRED",null]"#), + Ok(RelayMessage::TasksSyncRequired) + )); +} diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index 39ea6aaa898..a12162b3716 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -242,7 +242,7 @@ lifecycle hook — see [MCP_DRIVEN_HOOKS.md](../../docs/MCP_DRIVEN_HOOKS.md). | Block Gateway | `openai` | `POST {base}/chat/completions` | gpt-5, claude | | OpenRouter | `openrouter` | `POST {base}/chat/completions` | anything they route (extended-thinking replay, provider-agnostic tool calling) | | Databricks | `databricks` | `POST {host}/serving-endpoints/{model}/invocations` | goose-claude-4-6-sonnet | -| Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | workspace endpoints and Unity Catalog model-service FQNs; UC FQNs use MLflow Chat Completions | +| Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | workspace endpoints and Unity Catalog model-service FQNs; UC GPT-5+ services use OpenAI Responses; other UC FQNs use MLflow Chat Completions | The optional `DATABRICKS_MODEL_FILTER` applies only to model discovery. Each comma-separated entry is trimmed and matched against the complete raw ID with case-sensitive `*` (zero or more characters) and `?` (one Unicode character) semantics; patterns are OR-ed. Unset or blank preserves the full authenticated catalog. A nonblank value containing no usable patterns is rejected. This controls picker visibility only; Databricks and Unity Catalog permissions remain the authorization boundary. A filtered-empty result is authoritative and does not restore the built-in fallback models. diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 1bac5147743..c0eae9a4d27 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -2570,6 +2570,7 @@ fn apply_anthropic_cache_control(body: &mut serde_json::Map) { #[cfg(test)] mod tests { + include!("llm_fqn_tests.rs"); use super::*; use crate::config::{Config, HookServers, OpenAiApi, Provider, ThinkingSummary}; use crate::types::{HistoryItem, ToolCall, ToolResult, ToolResultContent}; diff --git a/crates/buzz-agent/src/llm_fqn_tests.rs b/crates/buzz-agent/src/llm_fqn_tests.rs new file mode 100644 index 00000000000..cf79358b862 --- /dev/null +++ b/crates/buzz-agent/src/llm_fqn_tests.rs @@ -0,0 +1,56 @@ +// Included in llm::tests to reuse the production-path HTTP capture fixture. +#[tokio::test] +async fn gpt_fqn_completion_and_summary_use_responses() { + let response = json!({"status":"completed", "output":[{ + "type":"message", "content":[{"type":"output_text", "text":"ok"}] + }]}); + let (base_url, captured) = spawn_sequence_stub(vec![ + StubHttpResponse::ok(response.clone()), + StubHttpResponse::ok(response), + ]) + .await; + let mut config = cfg(Provider::DatabricksV2); + config.base_url = base_url; + config.thinking_effort = Some(ThinkingEffort::High); + let model = "catalog.schema.goose-gpt-6-astra"; + let llm = Llm::new(&config).unwrap(); + let tools = vec![ToolDef { + name: "test_tool".into(), + description: "Test".into(), + input_schema: json!({"type":"object", "properties":{}}), + }]; + let result = llm + .complete( + &config, + "system", + &[HistoryItem::User("hello".into())], + &tools, + model, + ) + .await + .unwrap(); + assert_eq!(result.text, "ok"); + assert_eq!( + llm.summarize(&config, "system", "history", 128, model) + .await + .unwrap(), + "ok" + ); + let requests = captured.lock().await; + let posts: Vec<_> = requests.iter().filter(|r| r.method == "POST").collect(); + assert_eq!(posts.len(), 2); + for request in &posts { + assert_eq!(request.path, "/v1/ai-gateway/openai/v1/responses"); + let body = request.body.as_ref().unwrap(); + assert_eq!(body["model"], model); + assert!(body.get("input").is_some()); + assert!(body.get("messages").is_none()); + assert!(body.get("reasoning_effort").is_none()); + } + let completion = posts[0].body.as_ref().unwrap(); + assert!(completion["input"].is_array()); + assert_eq!(completion["reasoning"]["effort"], "high"); + assert_eq!(completion["tools"][0]["type"], "function"); + assert_eq!(completion["tools"][0]["name"], "test_tool"); + assert_eq!(posts[1].body.as_ref().unwrap()["max_output_tokens"], 128); +} diff --git a/crates/buzz-agent/src/model_capabilities.rs b/crates/buzz-agent/src/model_capabilities.rs index 53f2d290ff6..18a4726012b 100644 --- a/crates/buzz-agent/src/model_capabilities.rs +++ b/crates/buzz-agent/src/model_capabilities.rs @@ -292,8 +292,8 @@ fn prefix_matches(token: &str, s: &str) -> bool { /// /// Databricks Unity Catalog model-service names are catalog data, not model /// family hints. Both capability interpreters use this shape check before -/// family matching so suffixes such as `kimi-k3` cannot inherit endpoint -/// capabilities accidentally. +/// family matching so services cannot inherit endpoint capabilities accidentally. +/// GPT-5+ services have a route-only Responses exception. pub(crate) fn is_databricks_model_service_fqn(model: &str) -> bool { let mut components = model.split('.'); let (Some(catalog), Some(schema), Some(service)) = @@ -308,16 +308,35 @@ pub(crate) fn is_databricks_model_service_fqn(model: &str) -> bool { }) && components.next().is_none() } +/// Route GPT-5+ UC services to Responses without borrowing endpoint effort facts. +/// Match the first family token in the service only, preserving the existing +/// boundary semantics (e.g. `claude-gpt-5` is not a GPT service). +fn fqn_requires_responses(model: &str) -> bool { + let Some(service) = model.rsplit('.').next() else { + return false; + }; + let lower = service.to_ascii_lowercase(); + let stripped = strip_catalog_prefix(&lower, &manifest().family_tokens); + let Some(version) = stripped.strip_prefix("gpt-") else { + return false; + }; + let digits = version.bytes().take_while(u8::is_ascii_digit).count(); + let (major, suffix) = version.split_at(digits); + if suffix.starts_with(|c: char| c.is_ascii_alphanumeric()) { + return false; + } + major.parse::().is_ok_and(|major| major >= 5) +} + /// Resolve the capability profile for a `(provider, raw_model_id)` pair. pub fn resolve(provider: &str, raw_model_id: &str) -> CapabilityResult { let m = manifest(); let canon = canonical_provider(provider); let blank = raw_model_id.trim().is_empty(); - // Unity Catalog FQNs are neutral model-service identities. Resolve them - // through the concrete-unknown fallback before any suffix can match a - // provider family rule. Routing and effort normalization then share this - // one answer in Rust and TypeScript. + // FQNs keep neutral effort capabilities, but GPT-5+ service names need + // Responses for tools with reasoning. Only inspect the service component: + // catalog/schema names must never choose a model protocol. let model_service_fqn = canon == "databricks_v2" && is_databricks_model_service_fqn(raw_model_id); @@ -394,7 +413,11 @@ pub fn resolve(provider: &str, raw_model_id: &str) -> CapabilityResult { thinking_mode: state.thinking_mode, supported_efforts: &state.supported_efforts, default_effort: state.default_effort, - databricks_v2_wire_route: state.databricks_v2_wire_route, + databricks_v2_wire_route: if model_service_fqn && fqn_requires_responses(raw_model_id) { + DatabricksV2Route::OpenaiResponses + } else { + state.databricks_v2_wire_route + }, normalization_policy: state.normalization_policy, registry_label: None, } @@ -724,6 +747,25 @@ mod tests { Q::Vector { id: "boundary-claude-3-digit-run-anthropic-probe", provider: "anthropic", raw_model_id: "claude-35", note: Some("Probes whether the claude-3 prefix binds a longer digit run ('35').") }, Q::Vector { id: "boundary-claude-opus-4-70-anthropic-probe", provider: "anthropic", raw_model_id: "claude-opus-4-70", note: Some("Probes whether the claude-opus-4-7 prefix binds a longer digit run ('70').") }, Q::Vector { id: "boundary-gpt-5-1234-openai-probe", provider: "openai", raw_model_id: "gpt-5-1234", note: Some("Probes a 4-digit run after the gpt-5 stem.") }, + Q::Section { group: "Databricks FQN GPT-5+ Responses routing", note: Some("Only the service component selects Responses; effort capabilities remain neutral.") }, + Q::Vector { id: "dbv2-fqn-responses-0", provider: "databricks_v2", raw_model_id: "catalog.schema.goose-gpt-6-astra", note: None }, + Q::Vector { id: "dbv2-fqn-responses-1", provider: "databricks_v2", raw_model_id: "catalog.schema.goose-gpt-5", note: None }, + Q::Vector { id: "dbv2-fqn-responses-2", provider: "databricks_v2", raw_model_id: "catalog.schema.gpt-5-5", note: None }, + Q::Vector { id: "dbv2-fqn-responses-3", provider: "databricks_v2", raw_model_id: "catalog.schema.gpt-10", note: None }, + Q::Vector { id: "dbv2-fqn-responses-4", provider: "databricks_v2", raw_model_id: "catalog.schema.GOOSE-GPT-6-ASTRA", note: None }, + Q::Vector { id: "dbv2-fqn-responses-5", provider: "databricks_v2", raw_model_id: "gpt-6.schema.other", note: None }, + Q::Vector { id: "dbv2-fqn-responses-6", provider: "databricks_v2", raw_model_id: "catalog.gpt-5.other", note: None }, + Q::Vector { id: "dbv2-fqn-responses-7", provider: "databricks_v2", raw_model_id: "catalog.schema.claude-gpt-6", note: None }, + Q::Vector { id: "dbv2-fqn-responses-8", provider: "databricks_v2", raw_model_id: "catalog.schema.my-gpt-6-astra", note: None }, + Q::Vector { id: "dbv2-fqn-responses-9", provider: "databricks_v2", raw_model_id: "catalog.schema.mygpt-6-astra", note: None }, + Q::Vector { id: "dbv2-fqn-responses-10", provider: "databricks_v2", raw_model_id: "catalog.schema.gpt-4", note: None }, + Q::Vector { id: "dbv2-fqn-responses-11", provider: "databricks_v2", raw_model_id: "catalog.schema.gpt-4o", note: None }, + Q::Vector { id: "dbv2-fqn-responses-12", provider: "databricks_v2", raw_model_id: "catalog.schema.gpt-6x", note: None }, + Q::Vector { id: "dbv2-fqn-responses-13", provider: "databricks_v2", raw_model_id: "catalog.schema.gpt-oss-120b", note: None }, + Q::Vector { id: "dbv2-fqn-responses-14", provider: "databricks_v2", raw_model_id: "catalog.schema.gpt-", note: None }, + Q::Vector { id: "dbv2-fqn-responses-15", provider: "databricks_v2", raw_model_id: "catalog.schema.gpt-4294967296", note: None }, + Q::Vector { id: "dbv2-fqn-responses-16", provider: "databricks_v2", raw_model_id: "catalog.schema.gpt-6", note: None }, + Q::Vector { id: "dbv2-fqn-responses-17", provider: "databricks_v2", raw_model_id: "catalog.schema.kimi-k3", note: None }, Q::Section { group: "Databricks UC model-family humanization probes (#6918 follow-up)", note: Some("Exact-record and UC-FQN strip probes for the Gemini/DeepSeek/GLM/Grok/Llama/Qwen/Gemma/Inkling families surfaced by UC discovery.") }, Q::Vector { id: "dbv2-gemini-3-1-flash-image-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gemini-3-1-flash-image", note: Some("Probes the Gemini 3.1 Flash Image endpoint record and label.") }, Q::Vector { id: "dbv2-gemini-3-5-flash-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gemini-3-5-flash", note: Some("Probes the Gemini 3.5 Flash endpoint record and label.") }, @@ -844,7 +886,7 @@ mod tests { } #[test] - fn corpus_has_exactly_140_executable_vectors() { + fn corpus_has_exactly_158_executable_vectors() { // Locks the vector count so a silent INPUTS edit can't quietly drop // coverage; must equal the gate in the TS harness // (modelCapabilitiesCorpus.test.mjs). @@ -853,7 +895,7 @@ mod tests { .filter(|q| matches!(q, Q::Vector { .. })) .count(); assert_eq!( - vectors, 140, + vectors, 158, "corpus executable-vector count changed; update this gate deliberately" ); } diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index cd242aed451..53e5d052b76 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -971,10 +971,17 @@ async fn steer_rejected_when_no_active_run() { async fn steer_rejected_on_run_id_mismatch() { // A live run, but the caller targets a stale/wrong run id → invalid_params, // so the client falls back to cancel+merge instead of injecting blind. - let (url, _captures) = spawn_capturing_fake_llm(vec![ - openai_tool_call("call_x", "fake__noop", json!({})), - openai_text("done"), - ]) + // Hold the provider until rejection is observed, otherwise a fast turn can + // finish before the steer and exercise the no-active-run path instead. + let (gate_tx, gate_rx) = tokio::sync::oneshot::channel::<()>(); + let (url, _captures) = spawn_gated_capturing_fake_llm( + vec![CannedResponse { + status: 200, + body: openai_text("done"), + }], + Arc::new(Mutex::new(Vec::new())), + Arc::new(Mutex::new(Some(gate_rx))), + ) .await; let mut h = Harness::spawn(&url).await; let sid = init_session(&mut h).await; @@ -985,7 +992,7 @@ async fn steer_rejected_on_run_id_mismatch() { json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), ) .await; - let _live_run = recv_active_run_id(&mut h).await; + let live_run = recv_active_run_id(&mut h).await; let s_id = h .send( @@ -998,21 +1005,16 @@ async fn steer_rejected_on_run_id_mismatch() { ) .await; - let mut saw_reject = false; - for _ in 0..40 { - let v = h.recv().await; - if v["id"] == json!(s_id) { - assert_eq!( - v["error"]["code"], -32602, - "mismatched runId must be rejected" - ); - saw_reject = true; - } else if v["id"] == json!(p_id) { - // Turn finishes normally regardless of the rejected steer. - break; - } - } - assert!(saw_reject, "run-id mismatch was not rejected"); + let rejection = h.recv_until(|v| v["id"] == json!(s_id)).await; + assert_eq!(rejection["error"]["code"], -32602); + assert_eq!( + rejection["error"]["message"], + format!("steer: expected active run id `run_stale_mismatch` but found `{live_run}`"), + "must exercise the live run-id mismatch guard" + ); + let _ = gate_tx.send(()); + let finished = h.recv_until(|v| v["id"] == json!(p_id)).await; + assert_eq!(finished["result"]["stopReason"], "end_turn"); h.shutdown().await; } @@ -1335,7 +1337,7 @@ async fn mid_turn_usage_includes_earlier_turns() { /// Setup: round 1 is a tool call WITH usage (tokens are captured). After the /// tool_call_update notification (proving round 1 is fully processed), we gate /// the round-2 LLM response behind a `oneshot` barrier that only releases after -/// cancel is sent. This guarantees the turn exits with `stopReason: "cancelled"` +/// cancel is acknowledged. This guarantees the turn exits with `stopReason: "cancelled"` /// deterministically, even on a slow CI worker. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cancelled_turn_with_usage_emits_notification_before_response() { @@ -1350,7 +1352,7 @@ async fn cancelled_turn_with_usage_emits_notification_before_response() { // in-flight TCP request can resolve. The queue is empty for round 2, so the // agent receives the fallback "no canned response" body which it treats as // an LLM error; the cancel check at the round boundary fires first because - // the gate is only released after cancel is enqueued. + // the gate is only released after the agent acknowledges cancellation. let responses = vec![openai_tool_call_with_usage( "call_cancel_test", "fake__noop", @@ -1386,7 +1388,7 @@ async fn cancelled_turn_with_usage_emits_notification_before_response() { } } // For request 2+ (round 2), wait for the gate to open before - // responding. This ensures cancel is sent before round 2 resolves, + // responding. This ensures cancel is handled before round 2 resolves, // making stopReason: cancelled deterministic. if req_num >= 2 { let rx = gate.lock().await.take(); @@ -1430,10 +1432,10 @@ async fn cancelled_turn_with_usage_emits_notification_before_response() { }) .await; - // Now send cancel and release the round-2 gate. Cancel is enqueued before - // round 2 can respond, so the turn exits with stopReason: cancelled. + // Sending only flushes stdin; it does not prove the child handled cancel. + // Keep round 2 blocked until its acknowledgement arrives on the ACP wire. let c_id = h.send("session/cancel", json!({"sessionId": sid})).await; - let _ = gate_tx.send(()); // unblock round 2 + let mut gate_tx = Some(gate_tx); let mut saw_usage_before_prompt_response = false; let mut saw_usage = false; @@ -1442,7 +1444,11 @@ async fn cancelled_turn_with_usage_emits_notification_before_response() { for _ in 0..40 { let v = h.recv().await; if v["id"] == json!(c_id) { + assert_eq!(v.get("result"), Some(&Value::Null), "cancel must succeed"); saw_cancel_ok = true; + if let Some(gate) = gate_tx.take() { + let _ = gate.send(()); + } } else if is_usage_update(&v) { saw_usage = true; if !saw_prompt_response { @@ -1505,10 +1511,26 @@ fn openai_tool_call_with_usage( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn steer_rejected_on_empty_prompt() { - let (url, _captures) = spawn_capturing_fake_llm(vec![ - openai_tool_call("call_x", "fake__noop", json!({})), - openai_text("done"), - ]) + // Hold the first provider response until the steer rejection has been + // observed. Otherwise a fast fake provider can finish the run before the + // request is handled, making this validation race with normal teardown. + let (gate_tx, gate_rx) = tokio::sync::oneshot::channel::<()>(); + let mut gate_tx = Some(gate_tx); + let gate_rx = Arc::new(Mutex::new(Some(gate_rx))); + let (url, _captures) = spawn_gated_capturing_fake_llm( + vec![ + CannedResponse { + status: 200, + body: openai_tool_call("call_x", "fake__noop", json!({})), + }, + CannedResponse { + status: 200, + body: openai_text("done"), + }, + ], + Arc::new(Mutex::new(Vec::new())), + gate_rx, + ) .await; let mut h = Harness::spawn(&url).await; let sid = init_session(&mut h).await; @@ -1531,6 +1553,7 @@ async fn steer_rejected_on_empty_prompt() { if v["id"] == json!(s_id) { assert_eq!(v["error"]["code"], -32602, "empty prompt must be rejected"); saw_reject = true; + gate_tx.take().unwrap().send(()).unwrap(); } else if v["id"] == json!(p_id) { break; } diff --git a/crates/buzz-agent/tests/fqn_capabilities.rs b/crates/buzz-agent/tests/fqn_capabilities.rs new file mode 100644 index 00000000000..1c513dc0118 --- /dev/null +++ b/crates/buzz-agent/tests/fqn_capabilities.rs @@ -0,0 +1,63 @@ +//! UC route selection must change only the route, never effort or model identity. +use buzz_agent::model_capabilities::{resolve, DatabricksV2Route}; + +#[test] +fn gpt_fqn_route_preserves_neutral_capabilities() { + let fallback = resolve("databricks_v2", "catalog.schema.unknown"); + for model in [ + "catalog.schema.goose-gpt-6-astra", + "catalog.schema.gpt-5", + "catalog.schema.goose-gpt-5-5", + "catalog.schema.gpt-10", + "catalog.schema.GOOSE-GPT-6-ASTRA", + "catalog.schema.team-gpt-6-astra", + ] { + let got = resolve(" DATABRICKS-V2 ", model); + assert_eq!( + got.databricks_v2_wire_route, + DatabricksV2Route::OpenaiResponses, + "{model}" + ); + assert_eq!(got.thinking_mode, fallback.thinking_mode, "{model}"); + assert_eq!(got.supported_efforts, fallback.supported_efforts, "{model}"); + assert_eq!(got.default_effort, fallback.default_effort, "{model}"); + assert_eq!( + got.normalization_policy, fallback.normalization_policy, + "{model}" + ); + assert_eq!(got.registry_label, None, "{model}"); + } +} + +#[test] +fn unrelated_fqns_do_not_select_responses() { + for model in [ + "gpt-6.schema.other", + "catalog.gpt-5.other", + "catalog.schema.claude-gpt-6", + "catalog.schema.mygpt-6-astra", + "catalog.schema.gpt-4", + "catalog.schema.gpt-4o", + "catalog.schema.gpt-6x", + "catalog.schema.gpt-oss-120b", + "catalog.schema.gpt-", + "catalog.schema.gpt-6", + "catalog.schema.gpt-4294967296", + "catalog.schema.kimi-k3", + ] { + assert_eq!( + resolve("databricks_v2", model).databricks_v2_wire_route, + DatabricksV2Route::MlflowChat, + "{model}" + ); + } + // The new rule is not an endpoint-family or cross-provider capability change. + assert_eq!( + resolve("databricks_v2", "goose-gpt-6-astra").databricks_v2_wire_route, + DatabricksV2Route::MlflowChat + ); + assert_eq!( + resolve("openai", "catalog.schema.goose-gpt-6-astra").databricks_v2_wire_route, + DatabricksV2Route::NotApplicable + ); +} diff --git a/crates/buzz-audit/src/action.rs b/crates/buzz-audit/src/action.rs index be7ccc35454..04b41c67105 100644 --- a/crates/buzz-audit/src/action.rs +++ b/crates/buzz-audit/src/action.rs @@ -28,6 +28,10 @@ pub enum AuditAction { RateLimitExceeded, /// A media file was uploaded via the Blossom endpoint. MediaUploaded, + /// An agent was granted a capability on a target machine. + CapabilityGranted, + /// An agent's capability on a target machine was revoked. + CapabilityRevoked, } impl AuditAction { @@ -45,6 +49,8 @@ impl AuditAction { Self::AuthFailure => "auth_failure", Self::RateLimitExceeded => "rate_limit_exceeded", Self::MediaUploaded => "media_uploaded", + Self::CapabilityGranted => "capability_granted", + Self::CapabilityRevoked => "capability_revoked", } } @@ -60,6 +66,8 @@ impl AuditAction { Self::AuthFailure, Self::RateLimitExceeded, Self::MediaUploaded, + Self::CapabilityGranted, + Self::CapabilityRevoked, ]; } diff --git a/crates/buzz-cli/src/commands/cml.rs b/crates/buzz-cli/src/commands/cml.rs index b3041e0540e..00c20f8faa3 100644 --- a/crates/buzz-cli/src/commands/cml.rs +++ b/crates/buzz-cli/src/commands/cml.rs @@ -117,6 +117,53 @@ pub async fn cmd_events_publish( Ok(()) } +/// Sign and submit a strictly typed fleet receipt through ordinary event ingest. +/// Its attempt d-tag keeps it outside the CML task's lifecycle reduction. +pub async fn cmd_events_receipt( + client: &crate::client::BuzzClient, + channel: &str, + receipt_file: &str, + created_at: u64, +) -> Result<(), CliError> { + use buzz_core::fleet::{FleetReceipt, RECEIPT_PROTOCOL}; + use nostr::{EventBuilder, Kind, Tag, Timestamp}; + let channel = uuid::Uuid::parse_str(channel) + .map_err(|error| CliError::Usage(format!("invalid channel UUID: {error}")))?; + let receipt: FleetReceipt = serde_json::from_str(&read_input(receipt_file)?) + .map_err(|error| CliError::Usage(format!("invalid receipt: {error}")))?; + let content = receipt + .to_canonical_json() + .map_err(|error| CliError::Usage(error.to_string()))?; + let tags = [ + vec![ + "protocol".to_owned(), + RECEIPT_PROTOCOL.to_owned(), + "1".to_owned(), + ], + vec!["h".to_owned(), channel.to_string()], + vec!["d".to_owned(), receipt.attempt_id.clone()], + ] + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|error| CliError::Usage(error.to_string()))?; + let event = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_JOB_RESULT as u16), + content, + ) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(client.keys()) + .map_err(|error| CliError::Other(format!("sign receipt: {error}")))?; + FleetReceipt::from_event_after_signature(&event) + .map_err(|error| CliError::Usage(format!("self-check rejected receipt: {error}")))?; + let id = event.id.to_hex(); + let raw = client.submit_event(event).await?; + crate::commands::parse_write_response(&raw, "duplicate fleet receipt")?; + println!(r#"{{"accepted":true,"event_id":"{id}"}}"#); + Ok(()) +} + /// Run `buzz cml events reduce` — fetch and reduce a task's events. pub async fn cmd_events_reduce( client: &crate::client::BuzzClient, diff --git a/crates/buzz-cli/src/commands/machines.rs b/crates/buzz-cli/src/commands/machines.rs new file mode 100644 index 00000000000..2c248fb5889 --- /dev/null +++ b/crates/buzz-cli/src/commands/machines.rs @@ -0,0 +1,102 @@ +//! Signed private enrollment and observation, and owner-only machine reads. +use crate::{client::BuzzClient, error::CliError, validate::read_file_or_stdin, MachinesCmd}; +use buzz_core::machine::{ + verify_enrollment_consent, MachineCommand, MachineEnrollment, MachineEnrollmentConsent, +}; +use nostr::{EventBuilder, Kind, PublicKey, Timestamp}; +use uuid::Uuid; + +/// Execute one machine operation using the existing authenticated event transport. +pub async fn dispatch(command: MachinesCmd, client: &BuzzClient) -> Result<(), CliError> { + let response = match command { + MachinesCmd::Authorize { coordinator } => { + let coordinator = PublicKey::from_hex(&coordinator) + .map_err(|_| CliError::Usage("coordinator must be a public key".into()))?; + buzz_sdk::nip_oa::compute_auth_tag( + client.keys(), + &coordinator, + &format!("kind=47210&created_at<{}", Timestamp::now().as_secs() + 300), + ) + .map_err(crate::validate::sdk_err)? + } + MachinesCmd::Consent { file } => consent(client, &file)?, + MachinesCmd::List { after, limit } => { + if !(1..=100).contains(&limit) { + return Err(CliError::Usage("limit must be between 1 and 100".into())); + } + let mut path = format!("/api/machines?limit={limit}"); + if let Some(after) = after { + path.push_str(&format!("&after={after}")); + } + client.get_authed(&path).await? + } + MachinesCmd::Get { id } => client.get_authed(&format!("/api/machines/{id}")).await?, + MachinesCmd::Enroll { file } => { + publish(client, &file, buzz_core::kind::KIND_MACHINE_ENROLLMENT).await? + } + MachinesCmd::Observe { file } => { + publish(client, &file, buzz_core::kind::KIND_MACHINE_OBSERVATION).await? + } + }; + println!("{response}"); + Ok(()) +} + +async fn publish(client: &BuzzClient, file: &str, kind: u32) -> Result { + let content = read_file_or_stdin(file)?; + let event = EventBuilder::new(Kind::Custom(kind as u16), content) + .sign_with_keys(client.keys()) + .map_err(|_| CliError::Other("machine command signing failed".into()))?; + MachineCommand::from_event_after_signature(&event).map_err(CliError::Usage)?; + client.submit_event(event).await +} + +/// Parse a machine identifier without accepting hostnames or connection strings. +pub fn parse_id(value: &str) -> Result { + Uuid::parse_str(value).map_err(|_| "machine ID must be a UUID".into()) +} + +fn consent(client: &BuzzClient, file: &str) -> Result { + let content = read_file_or_stdin(file)?; + if content.len() > 2048 { + return Err(CliError::Usage("consent exceeds 2048 bytes".into())); + } + let consent: MachineEnrollmentConsent = serde_json::from_str(&content) + .map_err(|_| CliError::Usage("invalid consent document".into()))?; + let owner = PublicKey::from_hex(&consent.owner_pubkey) + .map_err(|_| CliError::Usage("invalid consent owner".into()))?; + let event = EventBuilder::new(Kind::Custom(47212), content) + .sign_with_keys(client.keys()) + .map_err(|_| CliError::Other("consent signing failed".into()))?; + let enrollment = MachineEnrollment { + version: consent.version, + community_id: consent.community_id, + machine_id: consent.machine_id, + coordinator_pubkey: consent.coordinator_pubkey, + label: consent.label, + runtime: consent.runtime, + owner_auth: consent.owner_auth, + coordinator_consent: event.clone(), + }; + verify_enrollment_consent( + &enrollment, + &owner, + event.created_at.as_secs(), + Timestamp::now().as_secs() as i64, + ) + .map_err(CliError::Usage)?; + let proof = serde_json::to_string(&enrollment.owner_auth) + .map_err(|_| CliError::Usage("invalid owner proof".into()))?; + let proved = buzz_sdk::nip_oa::verify_auth_tag_for_event( + &proof, + &client.keys().public_key(), + 47210, + event.created_at.as_secs(), + ) + .map_err(crate::validate::sdk_err)?; + if proved != owner { + return Err(CliError::Usage("consent owner does not match proof".into())); + } + serde_json::to_string(&event) + .map_err(|_| CliError::Other("consent serialization failed".into())) +} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index c9aa89737d4..6301d1f6c96 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -143,3 +143,5 @@ mod tests { .any(|tag| tag.as_slice().first().map(String::as_str) == Some("h"))); } } + +pub mod machines; diff --git a/crates/buzz-cli/src/commands/tasks.rs b/crates/buzz-cli/src/commands/tasks.rs index 6de86dc1026..161a6d754d8 100644 --- a/crates/buzz-cli/src/commands/tasks.rs +++ b/crates/buzz-cli/src/commands/tasks.rs @@ -183,6 +183,24 @@ pub async fn dispatch( print_value(&parse_response(&response)?, format); Ok(()) } + TasksCmd::Admission { + task, + attempt, + start_event, + } => { + let task = uuid("task", &task)?; + if !buzz_core::fleet::valid_attempt_id(&attempt) { + return Err(CliError::Usage("invalid fleet attempt id".into())); + } + crate::validate::validate_hex64(&start_event)?; + let response = client + .get_authed(&format!( + "/api/tasks/{task}/attempts/{attempt}/admission?start_event_id={start_event}", + )) + .await?; + print_value(&parse_response(&response)?, format); + Ok(()) + } TasksCmd::Create { title, body, @@ -222,6 +240,7 @@ pub async fn dispatch( clear_assignee, due_at, clear_due, + expected_revision, } => { let task = uuid("task", &task)?; let mut payload = Map::new(); @@ -249,6 +268,9 @@ pub async fn dispatch( "update requires at least one mutable field".into(), )); } + if let Some(value) = expected_revision { + payload.insert("expected_revision".into(), json!(value)); + } let response = client .patch_authed_json(&format!("/api/tasks/{task}"), &Value::Object(payload)) .await?; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 497fede3c2a..03f3e8aea8d 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -240,6 +240,9 @@ enum Cmd { /// Durable community work items for humans and agents #[command(subcommand)] Tasks(TasksCmd), + /// Private computer enrollment and coordinator observations + #[command(subcommand)] + Machines(MachinesCmd), /// Validate and canonicalize local Buzz CML task snapshots #[command(subcommand)] Cml(CmlCmd), @@ -1989,6 +1992,18 @@ pub enum CmlEventsCmd { #[arg(long)] prev: Option, }, + /// Sign and publish a typed terminal or unknown fleet receipt + Receipt { + /// Channel UUID hosting the task + #[arg(long)] + channel: String, + /// Path to the typed receipt JSON + #[arg(long)] + receipt_file: String, + /// Frozen publication timestamp; completion time remains in the receipt + #[arg(long)] + created_at: u64, + }, /// Fetch a task's CML events and print the observation-time workstream card Card { /// Channel UUID hosting the task @@ -2018,6 +2033,43 @@ pub enum PackCmd { }, } +/// Private machine commands; writes consume the versioned typed JSON document. +#[derive(Subcommand)] +pub enum MachinesCmd { + /// Create a short-lived NIP-OA enrollment proof as the owner (local only) + Authorize { + #[arg(long)] + coordinator: String, + }, + /// Sign exact enrollment consent as the coordinator (local only; never publishes) + Consent { + #[arg(long)] + file: String, + }, + /// List this signing owner's registered machines + List { + #[arg(long, value_parser=commands::machines::parse_id)] + after: Option, + #[arg(long, default_value_t = 50)] + limit: i64, + }, + /// Read one owned machine and its signed enrollment/current observation + Get { + #[arg(value_parser=commands::machines::parse_id)] + id: uuid::Uuid, + }, + /// Sign enrollment as the owner; JSON requires owner proof and coordinator-signed consent + Enroll { + #[arg(long)] + file: String, + }, + /// Sign a fresh observation as the enrolled coordinator + Observe { + #[arg(long)] + file: String, + }, +} + /// Durable task commands share the relay's host-derived community boundary. #[derive(Subcommand)] pub enum TasksCmd { @@ -2038,6 +2090,14 @@ pub enum TasksCmd { }, /// Get one task with its append-only event history Get { task: String }, + /// Recheck a particular newly accepted fleet start on the relay's primary + Admission { + task: String, + #[arg(long)] + attempt: String, + #[arg(long)] + start_event: String, + }, /// Create a task Create { #[arg(long)] @@ -2081,6 +2141,9 @@ pub enum TasksCmd { due_at: Option, #[arg(long, default_value_t = false)] clear_due: bool, + /// HW-017: reject the PATCH with 409 if the task's revision does not match + #[arg(long)] + expected_revision: Option, }, /// Append a progress/comment event; use '-' to read stdin Comment { task: String, body: String }, @@ -2300,6 +2363,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await, Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await, Cmd::Tasks(sub) => commands::tasks::dispatch(sub, &client, &cli.format).await, + Cmd::Machines(sub) => commands::machines::dispatch(sub, &client).await, Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await, Cmd::Cml(sub) => match sub { CmlCmd::Validate { path } => commands::cml::cmd_validate(path.as_str()), @@ -2326,6 +2390,14 @@ async fn run(cli: Cli) -> Result<(), CliError> { ) .await } + CmlEventsCmd::Receipt { + channel, + receipt_file, + created_at, + } => { + commands::cml::cmd_events_receipt(&client, &channel, &receipt_file, created_at) + .await + } CmlEventsCmd::Card { channel, task, @@ -2496,6 +2568,7 @@ mod tests { "feed", "gifs", "issues", + "machines", "media", "mem", "messages", @@ -2722,7 +2795,7 @@ mod tests { ("reactions", 3), ("repos", 5), ("social", 7), - ("tasks", 6), + ("tasks", 7), ("upload", 1), ("users", 5), ("workflows", 8), diff --git a/crates/buzz-core/src/cml.rs b/crates/buzz-core/src/cml.rs index e5c026dc6fe..f9a54933e12 100644 --- a/crates/buzz-core/src/cml.rs +++ b/crates/buzz-core/src/cml.rs @@ -521,7 +521,7 @@ fn is_lower_hex(value: &str) -> bool { .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) } -fn sort_json(value: Value) -> Value { +pub(crate) fn sort_json(value: Value) -> Value { match value { Value::Object(map) => { let sorted: BTreeMap<_, _> = map diff --git a/crates/buzz-core/src/cml_event.rs b/crates/buzz-core/src/cml_event.rs index 2cc40c68c47..e549a1890e2 100644 --- a/crates/buzz-core/src/cml_event.rs +++ b/crates/buzz-core/src/cml_event.rs @@ -399,6 +399,18 @@ fn validate_resolution_snapshot( Ok(()) } +/// Validate one exact successor after both event envelopes were validated. +/// A database projection can use this while holding its task/attempt row lock. +pub fn validate_successor( + previous: &ValidatedCmlEvent, + current: &ValidatedCmlEvent, +) -> Result<(), CmlEventError> { + if current.previous != Some(previous.id) { + return invalid("successor does not name current predecessor"); + } + validate_transition(previous, current) +} + fn validate_transition( previous: &ValidatedCmlEvent, current: &ValidatedCmlEvent, diff --git a/crates/buzz-core/src/fleet.rs b/crates/buzz-core/src/fleet.rs new file mode 100644 index 00000000000..423e0db56e4 --- /dev/null +++ b/crates/buzz-core/src/fleet.rs @@ -0,0 +1,249 @@ +//! Signed contracts for the fixed fleet repository-qualification operation. +//! +//! CML describes the plan; these types bind its execution to a relay admission. +//! A stored CML event by itself is never a fresh execution permit. + +use nostr::Event; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{cml::CmlTask, cml_event::CmlEventError, kind::KIND_JOB_RESULT, CommunityId}; + +/// Versioned opt-in extension. Older fleet plans have no server admission. +pub const EXTENSION: &str = "org.buzz.fleet.v2"; +/// Signed result/acknowledgement protocol; its `d` tag is the attempt, not task. +pub const RECEIPT_PROTOCOL: &str = "buzz-fleet-receipt"; + +/// The immutable authority and repository scope approved by the planner. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FleetScope { + /// Operator-configured host alias, never a command or address. + pub target: String, + /// Stable registered machine-home identity. + pub machine_id: String, + /// Operator-configured repository alias, never a filesystem path. + pub repository: String, + /// Only `qualify` is implemented. + pub capability: String, + /// Absolute deadline, at most one hour after the plan. + pub expires_at: u64, + /// Human task revision approved by this plan. + pub task_revision: i32, + /// Digest of the reviewed public scheduler/host policy. + pub policy_digest: String, +} + +impl FleetScope { + /// Read and validate the opt-in scope without interpreting other extensions. + pub fn from_task(task: &CmlTask) -> Result, CmlEventError> { + let Some(value) = task.extensions.get(EXTENSION) else { + return Ok(None); + }; + let scope: Self = serde_json::from_value(value.clone()).map_err(invalid)?; + for (name, value) in [ + ("target", &scope.target), + ("machine_id", &scope.machine_id), + ("repository", &scope.repository), + ] { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) + { + return Err(invalid(format!("invalid fleet {name}"))); + } + } + if scope.capability != "qualify" + || scope.task_revision < 0 + || !hex_id(&scope.policy_digest, 64) + { + return Err(invalid( + "unsupported fleet capability, revision, or policy digest", + )); + } + // Transitions retain this immutable deadline, so the plan-time upper + // bound is additionally checked when its root is admitted. + if scope.expires_at == 0 { + return Err(invalid("fleet deadline required")); + } + Ok(Some(scope)) + } +} + +/// Stable attempt identity, including the tenant rather than a display URL. +pub fn attempt_id(community: CommunityId, task: Uuid, plan_event_id: &[u8]) -> String { + let mut hash = Sha256::new(); + hash.update(b"buzz-fleet-attempt-v2\0"); + hash.update(community.as_uuid().as_bytes()); + hash.update(task.as_bytes()); + hash.update(plan_event_id); + format!("buzz-qualify-{}", hex::encode(hash.finalize())) +} + +/// Observed fixed-operation outcome. `unknown` never acknowledges stopping. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReceiptStatus { + /// Fixed probe finished successfully; independent review remains required. + Success, + /// Fixed probe returned a known error. + Error, + /// An accepted start has a known stopped outcome; any spawned probe exited. + Cancelled, + /// A local exclusive lock proved that execution never began. + CancelledBeforeExecution, + /// A prior start has no known terminal result; automatic replay is refused. + Unknown, +} + +/// Bounded output of the sole implemented operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Qualification { + /// Canonical repository ID approved in CML. + pub repository: String, + /// Observed Git commit, not a worktree cleanliness assertion. + pub head_sha: String, + /// Number of entries in the repository index. + pub tracked_files: u64, + /// Executing Python version. + pub python: String, +} + +/// Worker-signed execution fact, distinct from a planner's cancellation intent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FleetReceipt { + /// Stable relay-generated attempt ID. + pub attempt_id: String, + /// Human/CML task UUID. + pub task_id: Uuid, + /// Approved signed plan event ID. + pub plan_event_id: String, + /// Exact server-admitted start, absent only for a proven pre-start stop. + pub start_event_id: Option, + /// Registered execution machine. + pub machine_id: String, + /// Frozen public policy digest. + pub policy_digest: String, + /// Observed result, never review approval. + pub status: ReceiptStatus, + /// Probe output, present only on success. + pub qualification: Option, + /// Bounded error code, absent on success and pre-start cancellation. + pub error: Option, + /// Frozen Unix time for deterministic event ID recovery. + pub completed_at: u64, +} + +impl FleetReceipt { + /// Canonical signed representation shared by the relay and native CLI. + pub fn to_canonical_json(&self) -> Result { + serde_json::to_value(self) + .map(crate::cml::sort_json) + .and_then(|v| serde_json::to_string(&v)) + .map_err(invalid) + } + + /// Validate the signed envelope after the ingest pipeline verified NIP-01. + pub fn from_event_after_signature(event: &Event) -> Result<(Uuid, Self), CmlEventError> { + let tag = |name: &str| -> Result, CmlEventError> { + let all: Vec<_> = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(String::as_str) == Some(name)) + .collect(); + if all.len() != 1 { + return Err(invalid(format!("one {name} tag required"))); + } + Ok(all[0].as_slice().to_vec()) + }; + if u32::from(event.kind.as_u16()) != KIND_JOB_RESULT + || tag("protocol")? != ["protocol", RECEIPT_PROTOCOL, "1"] + { + return Err(invalid("invalid fleet receipt kind/protocol")); + } + let h = tag("h")?; + let d = tag("d")?; + if h.len() != 2 || d.len() != 2 || event.content.len() > 8192 { + return Err(invalid("invalid fleet receipt envelope")); + } + let channel = h[1].parse::().map_err(invalid)?; + let receipt: Self = serde_json::from_str(&event.content).map_err(invalid)?; + if receipt.to_canonical_json()? != event.content + || d[1] != receipt.attempt_id + || !valid_attempt_id(&receipt.attempt_id) + { + return Err(invalid("noncanonical or mismatched fleet receipt")); + } + if !hex_id(&receipt.plan_event_id, 64) + || !hex_id(&receipt.policy_digest, 64) + || receipt.completed_at == 0 + || receipt.completed_at > event.created_at.as_secs() + { + return Err(invalid("invalid fleet receipt identity/time")); + } + let pre_start = receipt.status == ReceiptStatus::CancelledBeforeExecution; + if receipt + .start_event_id + .as_ref() + .is_some_and(|id| !hex_id(id, 64)) + || pre_start != receipt.start_event_id.is_none() + { + return Err(invalid("fleet receipt start binding required")); + } + if (receipt.status == ReceiptStatus::Success) != receipt.qualification.is_some() + || receipt + .error + .as_ref() + .is_some_and(|e| e.is_empty() || e.len() > 256 || e.chars().any(char::is_control)) + { + return Err(invalid("invalid fleet result shape")); + } + if let Some(q) = &receipt.qualification { + if (!hex_id(&q.head_sha, 40) && !hex_id(&q.head_sha, 64)) + || q.repository.is_empty() + || q.repository.len() > 256 + || q.python.is_empty() + || q.python.len() > 64 + || receipt.error.is_some() + { + return Err(invalid("invalid qualification output")); + } + } + Ok((channel, receipt)) + } +} + +/// Recognize only this versioned receipt protocol; unknown protocols still fail. +pub fn is_receipt(event: &Event) -> bool { + event.tags.iter().any(|t| { + t.as_slice().first().map(String::as_str) == Some("protocol") + && t.as_slice().get(1).map(String::as_str) == Some(RECEIPT_PROTOCOL) + }) +} + +/// Check the fixed attempt-ID wire shape without interpreting it as authority. +pub fn valid_attempt_id(value: &str) -> bool { + value + .strip_prefix("buzz-qualify-") + .is_some_and(|v| hex_id(v, 64)) +} + +fn hex_id(value: &str, len: usize) -> bool { + value.len() == len + && value + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) +} + +fn invalid(error: impl std::fmt::Display) -> CmlEventError { + CmlEventError::Invalid(error.to_string()) +} + +#[cfg(test)] +#[path = "fleet_tests.rs"] +mod tests; diff --git a/crates/buzz-core/src/fleet_tests.rs b/crates/buzz-core/src/fleet_tests.rs new file mode 100644 index 00000000000..f4424f5efc8 --- /dev/null +++ b/crates/buzz-core/src/fleet_tests.rs @@ -0,0 +1,117 @@ +use super::*; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + +fn receipt() -> FleetReceipt { + FleetReceipt { + attempt_id: format!("buzz-qualify-{}", "a".repeat(64)), + task_id: Uuid::new_v4(), + plan_event_id: "b".repeat(64), + start_event_id: Some("c".repeat(64)), + machine_id: "mack".into(), + policy_digest: "d".repeat(64), + status: ReceiptStatus::Success, + qualification: Some(Qualification { + repository: "mfethe1/buzz".into(), + head_sha: "e".repeat(40), + tracked_files: 7, + python: "3.14".into(), + }), + error: None, + completed_at: 100, + } +} +fn event(body: &FleetReceipt, time: u64) -> Event { + EventBuilder::new( + Kind::Custom(KIND_JOB_RESULT as u16), + body.to_canonical_json().unwrap(), + ) + .tags([ + Tag::parse(["protocol", RECEIPT_PROTOCOL, "1"]).unwrap(), + Tag::parse(["h", &Uuid::new_v4().to_string()]).unwrap(), + Tag::parse(["d", &body.attempt_id]).unwrap(), + ]) + .custom_created_at(Timestamp::from(time)) + .sign_with_keys(&Keys::generate()) + .unwrap() +} + +#[test] +fn signed_receipt_keeps_completion_time_across_delayed_publication() { + let body = receipt(); + let event = event(&body, 10_000); + event.verify().unwrap(); + assert_eq!( + FleetReceipt::from_event_after_signature(&event).unwrap().1, + body + ); + assert_ne!(event.created_at.as_secs(), body.completed_at); + assert!(is_receipt(&event)); +} + +#[test] +fn receipt_rejects_future_completion_missing_start_and_noncanonical_content() { + let body = receipt(); + assert!(FleetReceipt::from_event_after_signature(&event(&body, 99)).is_err()); + let mut absent = body.clone(); + absent.start_event_id = None; + assert!(FleetReceipt::from_event_after_signature(&event(&absent, 100)).is_err()); + let original = event(&body, 100); + let noncanonical = EventBuilder::new(original.kind, format!("{}\n", original.content)) + .tags(original.tags.clone()) + .custom_created_at(original.created_at) + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert!(FleetReceipt::from_event_after_signature(&noncanonical).is_err()); + let duplicate = EventBuilder::new(original.kind, original.content.clone()) + .tags( + original + .tags + .iter() + .cloned() + .chain([Tag::parse(["d", &body.attempt_id]).unwrap()]), + ) + .custom_created_at(original.created_at) + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert!(FleetReceipt::from_event_after_signature(&duplicate).is_err()); +} + +#[test] +fn unknown_is_not_success_or_proof_of_stopping() { + let mut body = receipt(); + body.status = ReceiptStatus::Unknown; + assert!(FleetReceipt::from_event_after_signature(&event(&body, 100)).is_err()); + body.qualification = None; + assert_eq!( + FleetReceipt::from_event_after_signature(&event(&body, 100)) + .unwrap() + .1 + .status, + ReceiptStatus::Unknown + ); + body.status = ReceiptStatus::CancelledBeforeExecution; + assert!(FleetReceipt::from_event_after_signature(&event(&body, 100)).is_err()); + body.start_event_id = None; + assert_eq!( + FleetReceipt::from_event_after_signature(&event(&body, 100)) + .unwrap() + .1 + .status, + ReceiptStatus::CancelledBeforeExecution + ); +} + +#[test] +fn attempt_identity_binds_community_task_and_signed_plan() { + let community = CommunityId::from_uuid(Uuid::new_v4()); + let task = Uuid::new_v4(); + let id = attempt_id(community, task, &[1; 32]); + assert!(valid_attempt_id(&id)); + assert_eq!(id, attempt_id(community, task, &[1; 32])); + assert_ne!( + id, + attempt_id(CommunityId::from_uuid(Uuid::new_v4()), task, &[1; 32]) + ); + assert_ne!(id, attempt_id(community, Uuid::new_v4(), &[1; 32])); + assert_ne!(id, attempt_id(community, task, &[2; 32])); +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 35b1793b82d..33dd7417a32 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -683,6 +683,9 @@ pub const KIND_PROJECT: u32 = 30621; /// All registered kind constants — used for duplicate detection and iteration. pub const ALL_KINDS: &[u32] = &[ + KIND_MACHINE_ENROLLMENT_CONSENT, + KIND_MACHINE_ENROLLMENT, + KIND_MACHINE_OBSERVATION, KIND_PROFILE, KIND_TEXT_NOTE, KIND_CONTACT_LIST, @@ -947,6 +950,14 @@ const _: () = assert!(is_moderation_command_kind(KIND_MODERATION_BAN)); const _: () = assert!(is_moderation_command_kind(KIND_MODERATION_RESOLVE_REPORT)); const _: () = assert!(!is_moderation_command_kind(KIND_REPORT)); +/// Owner-signed private machine enrollment (never ordinary event storage). +pub const KIND_MACHINE_ENROLLMENT: u32 = 47210; +/// Coordinator-signed private machine observation (never ordinary event storage). +pub const KIND_MACHINE_OBSERVATION: u32 = 47211; + +/// Embedded coordinator enrollment consent; standalone submission is forbidden. +pub const KIND_MACHINE_ENROLLMENT_CONSENT: u32 = 47212; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 38fc3ee7394..60b4647ff10 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -25,6 +25,8 @@ pub mod error; pub mod event; /// NIP-01 subscription filter matching. pub mod filter; +/// Fixed fleet execution scope and worker receipt contracts. +pub mod fleet; /// Git permission types — ref patterns, protection rules, policy evaluation. pub mod git_perms; /// Shared invite-link contract constants. @@ -88,3 +90,6 @@ pub mod test_helpers { StoredEvent::with_received_at(make_event(kind), Utc::now(), channel_id, true) } } + +/// Private machine control wire types. +pub mod machine; diff --git a/crates/buzz-core/src/machine.rs b/crates/buzz-core/src/machine.rs new file mode 100644 index 00000000000..5124511cdd5 --- /dev/null +++ b/crates/buzz-core/src/machine.rs @@ -0,0 +1,287 @@ +//! Private machine enrollment and observations. These commands are never feed events. + +use nostr::{Event, PublicKey}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Server freshness bound; observations are reports, never execution authority. +pub const OBSERVATION_TTL_SECS: i64 = 120; +/// Maximum age of a newly received observation. +pub const MAX_OBSERVATION_AGE_SECS: i64 = 30; +/// Maximum tolerated forward clock skew. +pub const MAX_CLOCK_SKEW_SECS: i64 = 5; + +/// Runtime identity, independent of the machine's mutable presentation label. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum MachineRuntime { + /// Hermes coordinator. + Hermes, + /// OpenClaw coordinator. + Openclaw, + /// Codex coordinator. + Codex, + /// Claude Code coordinator. + ClaudeCode, +} + +impl MachineRuntime { + /// Stable storage value, also used for the existing agent type/home binding. + pub fn as_str(self) -> &'static str { + match self { + Self::Hermes => "hermes", + Self::Openclaw => "openclaw", + Self::Codex => "codex", + Self::ClaudeCode => "claude-code", + } + } +} + +/// Owner-signed registration. The outer signature binds the community and machine; +/// the NIP-OA proof must independently bind this owner to the coordinator. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MachineEnrollment { + /// Wire version (exactly 1). + pub version: u32, + /// Must equal the server-resolved tenant. + pub community_id: Uuid, + /// Opaque stable identifier, not a hostname or connection address. + pub machine_id: Uuid, + /// Coordinator's lowercase public key. + pub coordinator_pubkey: String, + /// Human-readable display label; no connection credentials or paths. + pub label: String, + /// Runtime serving this machine. + pub runtime: MachineRuntime, + /// Canonical NIP-OA auth tag, verified against the coordinator and this action. + pub owner_auth: [String; 4], + /// Short-lived coordinator consent bound to every enrollment field. + pub coordinator_consent: Event, +} + +/// Coordinator consent is embedded in owner enrollment only. Standalone consent +/// events are rejected by ingest, and never enter ordinary storage or fanout. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MachineEnrollmentConsent { + /// Consent format version (exactly 1). + pub version: u32, + /// Exact tenant audience. + pub community_id: Uuid, + /// Exact stable machine identity. + pub machine_id: Uuid, + /// Exact enrollment owner. + pub owner_pubkey: String, + /// Exact consenting coordinator. + pub coordinator_pubkey: String, + /// Exact approved presentation label. + pub label: String, + /// Exact approved runtime. + pub runtime: MachineRuntime, + /// Exact owner delegation accepted by the coordinator. + pub owner_auth: [String; 4], + /// Epoch-second expiry, at most 300 seconds after the consent signature. + pub expires_at: i64, +} + +/// Verify coordinator possession and exact enrollment consent at server time. +/// CPU-bound: async callers must run this in a blocking worker. +pub fn verify_enrollment_consent( + enrollment: &MachineEnrollment, + owner: &PublicKey, + enrollment_created_at: u64, + now: i64, +) -> Result<(), String> { + crate::verify_event(&enrollment.coordinator_consent) + .map_err(|_| "invalid coordinator consent signature")?; + validate_enrollment_consent_after_signature(enrollment, owner, enrollment_created_at, now) +} + +/// Check exact binding and current validity after signature verification. The +/// transaction repeats this check against its primary clock before persistence. +pub fn validate_enrollment_consent_after_signature( + enrollment: &MachineEnrollment, + owner: &PublicKey, + enrollment_created_at: u64, + now: i64, +) -> Result<(), String> { + let event = &enrollment.coordinator_consent; + if event.kind.as_u16() as u32 != crate::kind::KIND_MACHINE_ENROLLMENT_CONSENT + || !event.tags.is_empty() + || event.content.len() > 2048 + || event.pubkey.to_hex() != enrollment.coordinator_pubkey + { + return Err("invalid coordinator consent envelope".into()); + } + let consent: MachineEnrollmentConsent = + serde_json::from_str(&event.content).map_err(|_| "invalid coordinator consent")?; + let signed = i64::try_from(event.created_at.as_secs()).map_err(|_| "invalid consent time")?; + let outer = i64::try_from(enrollment_created_at).map_err(|_| "invalid enrollment time")?; + if consent.version != 1 + || consent.community_id != enrollment.community_id + || consent.machine_id != enrollment.machine_id + || consent.owner_pubkey != owner.to_hex() + || consent.coordinator_pubkey != enrollment.coordinator_pubkey + || consent.label != enrollment.label + || consent.runtime != enrollment.runtime + || consent.owner_auth != enrollment.owner_auth + || signed > now + MAX_CLOCK_SKEW_SECS + || signed < now - 300 + || consent.expires_at <= now + || consent.expires_at > signed.saturating_add(300) + || outer < signed - MAX_CLOCK_SKEW_SECS + || outer >= consent.expires_at + { + return Err("coordinator consent does not match live enrollment".into()); + } + Ok(()) +} + +/// The coordinator's bounded report; this does not assert verified host health. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MachineState { + /// Coordinator reports it can receive work. + Ready, + /// Coordinator reports ongoing work. + Busy, + /// Coordinator reports it cannot currently receive work. + Unavailable, +} + +/// Coordinator-signed report bound to one immutable enrollment. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MachineObservation { + /// Wire version (exactly 1). + pub version: u32, + /// Must equal the server-resolved tenant. + pub community_id: Uuid, + /// Registered stable machine identifier. + pub machine_id: Uuid, + /// Exact enrollment event, preventing reports from crossing registrations. + pub registration_event_id: String, + /// Strictly increasing, JSON-safe positive sequence number. + pub sequence: i64, + /// Reported availability, not execution permission. + pub state: MachineState, +} + +/// Closed command set accepted only by the private machine store. +#[derive(Debug, Clone)] +pub enum MachineCommand { + /// Establish immutable ownership and coordinator binding. + Enroll(Box), + /// Refresh the current observation after registration validation. + Observe(MachineObservation), +} + +fn hex32(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) +} + +impl MachineCommand { + /// Parse the closed bounded wire shape after transport signature verification. + /// No tags are accepted: routing, mentions and workflow tags have no role here. + pub fn from_event_after_signature(event: &Event) -> Result { + if !event.tags.is_empty() || event.content.len() > 4096 { + return Err("machine commands require no tags and at most 4096 content bytes".into()); + } + let command = match event.kind.as_u16() as u32 { + crate::kind::KIND_MACHINE_ENROLLMENT => { + let value: MachineEnrollment = serde_json::from_str(&event.content) + .map_err(|_| "invalid machine enrollment")?; + if !hex32(&value.coordinator_pubkey) + || PublicKey::from_hex(&value.coordinator_pubkey).is_err() + || value.coordinator_pubkey == event.pubkey.to_hex() + || value.label.trim() != value.label + || value.label.is_empty() + || value.label.len() > 80 + || value.label.chars().any(char::is_control) + { + return Err("invalid coordinator or machine label".into()); + } + Self::Enroll(Box::new(value)) + } + crate::kind::KIND_MACHINE_OBSERVATION => { + let value: MachineObservation = serde_json::from_str(&event.content) + .map_err(|_| "invalid machine observation")?; + if !hex32(&value.registration_event_id) + || !(1..=9_007_199_254_740_991).contains(&value.sequence) + { + return Err("invalid registration or observation sequence".into()); + } + Self::Observe(value) + } + _ => return Err("not a machine command".into()), + }; + let version = match &command { + Self::Enroll(v) => v.version, + Self::Observe(v) => v.version, + }; + if version != 1 || command.machine_id().is_nil() || command.community_id().is_nil() { + return Err("unsupported machine version or empty identity".into()); + } + Ok(command) + } + + /// Signed community audience. + pub fn community_id(&self) -> Uuid { + match self { + Self::Enroll(v) => v.community_id, + Self::Observe(v) => v.community_id, + } + } + + /// Stable registered machine. + pub fn machine_id(&self) -> Uuid { + match self { + Self::Enroll(v) => v.machine_id, + Self::Observe(v) => v.machine_id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use serde_json::json; + #[test] + fn machine_wire_rejects_unknown_duplicate_fields_tags_and_unbounded_sequences() { + let keys = Keys::generate(); + let base = json!({"version":1,"community_id":Uuid::new_v4(),"machine_id":Uuid::new_v4(),"registration_event_id":"a".repeat(64),"sequence":1,"state":"ready"}); + let sign = |content: String| { + EventBuilder::new(Kind::Custom(47211), content) + .sign_with_keys(&keys) + .unwrap() + }; + assert!(MachineCommand::from_event_after_signature(&sign(base.to_string())).is_ok()); + for (key, value) in [ + ("version", json!(2)), + ("sequence", json!(0)), + ("sequence", json!(9007199254740992i64)), + ("state", json!("running")), + ("ssh_path", json!("secret")), + ("registration_event_id", json!("A".repeat(64))), + ] { + let mut bad = base.clone(); + bad[key] = value; + assert!( + MachineCommand::from_event_after_signature(&sign(bad.to_string())).is_err(), + "{key}" + ); + } + let duplicate = base.to_string().replacen("{", "{\"sequence\":2,", 1); + assert!(MachineCommand::from_event_after_signature(&sign(duplicate)).is_err()); + let tagged = EventBuilder::new(Kind::Custom(47211), base.to_string()) + .tags([Tag::parse(["h", "private"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + assert!(MachineCommand::from_event_after_signature(&tagged).is_err()); + } +} diff --git a/crates/buzz-core/src/task.rs b/crates/buzz-core/src/task.rs index 744bc0abbd8..5d7d49a6127 100644 --- a/crates/buzz-core/src/task.rs +++ b/crates/buzz-core/src/task.rs @@ -82,9 +82,9 @@ impl FromStr for TaskStatus { /// A row in the append-only `task_events` log. /// -/// Stored as free `TEXT` rather than a database enum so a new action can ship -/// across a rolling upgrade without a migration; this enum is the set the -/// relay itself writes. +/// Stored as free `TEXT` rather than a database enum. New spellings do not +/// require a constraint migration, but readers must understand a spelling +/// before writers emit it; parsing an unknown action intentionally fails. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TaskAction { /// The task was created. @@ -97,6 +97,10 @@ pub enum TaskAction { Commented, /// `title` changed. TitleChanged, + /// `priority` changed. + PriorityChanged, + /// `due_at` changed or was cleared. + DueAtChanged, /// An agent persisted its summary of the task. At most one per task. SummaryPersisted, } @@ -110,6 +114,8 @@ impl TaskAction { Self::Assigned => "assigned", Self::Commented => "commented", Self::TitleChanged => "title_changed", + Self::PriorityChanged => "priority_changed", + Self::DueAtChanged => "due_at_changed", Self::SummaryPersisted => "summary_persisted", } } @@ -138,6 +144,8 @@ impl FromStr for TaskAction { "assigned" => Ok(Self::Assigned), "commented" => Ok(Self::Commented), "title_changed" => Ok(Self::TitleChanged), + "priority_changed" => Ok(Self::PriorityChanged), + "due_at_changed" => Ok(Self::DueAtChanged), "summary_persisted" => Ok(Self::SummaryPersisted), other => Err(format!("unknown task action: {other:?}")), } @@ -185,6 +193,8 @@ mod tests { TaskAction::Assigned, TaskAction::Commented, TaskAction::TitleChanged, + TaskAction::PriorityChanged, + TaskAction::DueAtChanged, TaskAction::SummaryPersisted, ] { assert_eq!(action.as_str().parse::(), Ok(action)); @@ -263,6 +273,8 @@ mod tests { TaskAction::Assigned, TaskAction::Commented, TaskAction::TitleChanged, + TaskAction::PriorityChanged, + TaskAction::DueAtChanged, ] { assert!(!action.is_singleton_per_task()); } diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index 4f4e6b105c5..e636ebf5771 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -45,6 +45,19 @@ pub enum DbError { #[error("invalid data: {0}")] InvalidData(String), + /// A PATCH carried `expected_revision` that does not match the row's current + /// `revision`. The caller must re-fetch and retry. This is the optimistic + /// concurrency guard from HW-017: a stale write must not silently win. + #[error("task {task_id} revision mismatch: expected {expected}, found {actual}")] + StaleRevision { + /// The task that was being patched. + task_id: uuid::Uuid, + /// The revision the caller expected (the snapshot it read). + expected: i32, + /// The revision the row actually carries. + actual: i32, + }, + /// A serving write admitted before the lifecycle transition is still live. /// This is an ordinary retryable drain condition, not a safety violation. #[error( diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 44815c7baca..08e762ec9d5 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -49,13 +49,15 @@ pub(crate) use runtime::{ insert_mentions_in_transaction, observability, route_proof, ReadSessionInner, RouteDecision, RoutePredicate, }; +pub use store::machine; pub use store::{ - admin_moderation, allowlist, api_token, archived_identities, channel, channel_members, - community, deletion, dm, event, feed, git_repo, moderation, partition, product_feedback, push, - reaction, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, - replaceable, thread, usage, user, workflow, + admin_moderation, agent_capability_grants, allowlist, api_token, archived_identities, channel, + channel_members, community, deletion, dm, event, feed, fleet_attempt, git_repo, moderation, + partition, product_feedback, push, reaction, relay_admin_actions, relay_invite, relay_members, + relay_operators, reminder, replaceable, thread, usage, user, workflow, }; +pub use agent_capability_grants::{CapabilityGrant, CAP_CROSS_SSH, TARGET_ANY}; pub use allowlist::AllowlistEntry; pub use api_token::{ApiTokenRecord, TokenSummary}; pub use community::{ diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index fbc8c79e98b..2f20aec8ddb 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -703,8 +703,48 @@ mod postgres_tests { migrations.sort_by_key(|migration| migration.version); // upstream carries 44 (0032-0034 and 0040 adopted from our PRs); - // fork adds 0046_task_system (PR #6425 pending upstream). - assert_eq!(migrations.len(), 45); + // fork adds 0046_task_system (PR #6425 pending upstream) and 0047 + // structured task history, 0048 machine homes, 0049 capability grants, + // 0050 task revisions, 0051 workflow approvals, 0052 fleet admission, and + // 0053 private machine control. + // Deployed migration checksums stay unchanged. + assert_eq!(migrations.len(), 52); + assert_eq!(migrations[51].version, 53); + assert!(migrations[51] + .sql + .as_str() + .contains("CREATE TABLE machine_control_events")); + assert_eq!(migrations[44].version, 46); + assert_eq!(migrations[45].version, 47); + assert_eq!(migrations[46].version, 48); + assert!(migrations[46] + .sql + .as_str() + .contains("ADD COLUMN machine_id")); + assert_eq!(migrations[47].version, 49); + assert!(migrations[47] + .sql + .as_str() + .contains("CREATE TABLE agent_capability_events")); + assert_eq!(migrations[48].version, 50); + assert!(migrations[48] + .sql + .as_str() + .contains("CREATE TRIGGER trg_tasks_revision")); + let task_changes = migrations[45].sql.as_str(); + assert!(task_changes.contains("ALTER TABLE task_events ADD COLUMN changes JSONB")); + assert!(task_changes.contains("ALTER COLUMN created_at SET DEFAULT clock_timestamp()")); + assert!(!migrations[44].sql.as_str().contains("ADD COLUMN changes")); + assert_eq!(migrations[50].version, 52); + assert!(migrations[50] + .sql + .as_str() + .contains("CREATE TABLE fleet_attempts")); + assert_eq!(migrations[49].version, 51); + assert!(migrations[49] + .sql + .as_str() + .contains("workflow_approvals_native_decision_required")); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1706,11 +1746,11 @@ mod postgres_tests { /// desired-state bootstrap schema (`schema/schema.sql`). /// /// Compares parsed statements, not substrings: every deletion control- - /// plane table, function, trigger, and index 0028 creates must exist in + /// plane table, function, trigger, and index 0029 creates must exist in /// schema.sql with an identical normalized definition; every operator- - /// global registry row 0028 inserts must be inserted by schema.sql; the + /// global registry row 0029 inserts must be inserted by schema.sql; the /// write-fence attachment target sets must be equal; and every column - /// 0028 adds to `communities` must exist in the desired-state + /// 0029 adds to `communities` must exist in the desired-state /// `communities` table. A desired-state bootstrap that passes this test /// cannot silently omit part of the deletion surface the way the /// pre-parity schema.sql omitted `community_deletion_manifest_keys` (and @@ -1840,8 +1880,23 @@ mod postgres_tests { .get(table) .unwrap_or_else(|| panic!("schema.sql is missing deletion table {table}")); if table != "community_deletion_requests" { + // Keep the historical migration immutable. pgSchema drops + // CHECK predicates containing IS NOT NULL, so the desired + // schema uses equivalent scalar num_nonnulls expressions. + // Permit only these two known rewrites; compare every other + // part of the table definition exactly as before. + let definition = if table == "community_deletion_checkpoints" { + definition + .replace( + "(completed_at is not null)", + "(num_nonnulls(completed_at) = 1)", + ) + .replace("(error is not null)", "(num_nonnulls(error) = 1)") + } else { + definition.clone() + }; assert_eq!( - in_schema, definition, + in_schema, &definition, "schema.sql definition of {table} drifted from migration 0029" ); } @@ -1883,13 +1938,29 @@ mod postgres_tests { let mut expected_fences = migration.fence_attachments.clone(); expected_fences.remove("product_feedback"); expected_fences.remove("rate_limit_violations"); - // Tenant tables introduced after 0029 declare their own fence - // attachment in their own migration and in schema.sql. Enumerate them - // here so the comparison below stays an exact equality: a new scoped - // table that forgets its fence line still fails this test, and a fence - // line for a table nobody registered here fails it too. - for post_0029_scoped_table in ["tasks", "task_events"] { - expected_fences.insert(post_0029_scoped_table.to_owned()); + // Keep later tenant tables explicit, and bind each attachment to the + // migration introducing it. Both upgrade and bootstrap must fence it; + // neither a missing attachment nor an unregistered extra is accepted. + for (version, table) in [ + (46, "tasks"), + (46, "task_events"), + (49, "agent_capability_grants"), + (49, "agent_capability_events"), + (52, "fleet_attempts"), + (53, "machines"), + (53, "machine_control_events"), + ] { + let introduced = MIGRATOR + .iter() + .find(|migration| migration.version == version) + .expect("embedded tenant-table migration"); + assert!( + surface(introduced.sql.as_ref()) + .fence_attachments + .contains(table), + "migration {version} is missing the write-fence attachment for {table}" + ); + expected_fences.insert(table.to_owned()); } assert_eq!( expected_fences, schema.fence_attachments, @@ -1915,7 +1986,7 @@ mod postgres_tests { for column in &migration.communities_added_columns { assert!( column_names.contains(column), - "schema.sql communities table is missing 0028 column {column}" + "schema.sql communities table is missing 0029 column {column}" ); } assert!(!migration.communities_added_columns.is_empty()); @@ -2821,10 +2892,16 @@ mod postgres_tests { "all NIP-FI tables must be absent after migration 0044: {present:?}" ); - // The deletion catalog must validate with ledger relations gone. + // The current deletion catalog includes tables introduced after 0044. + // Keep the historical removal assertion above, then advance the schema + // before checking compatibility with the current runtime catalog. + MIGRATOR + .run(&pool) + .await + .expect("apply remaining migrations after ledger removal"); crate::deletion::DeletionStore::new(pool.clone()) .validate_catalog() .await - .expect("deletion catalog validates after migration 0044"); + .expect("current deletion catalog validates after ledger removal and upgrade"); } } diff --git a/crates/buzz-db/src/store/agent_capability_grants.rs b/crates/buzz-db/src/store/agent_capability_grants.rs new file mode 100644 index 00000000000..5c7271741a6 --- /dev/null +++ b/crates/buzz-db/src/store/agent_capability_grants.rs @@ -0,0 +1,217 @@ +//! Per-machine capability grants for agents. +//! +//! Default deny: [`is_granted`] returns `false` unless an active (non-revoked) +//! row exists. Revocation is a tombstone rather than a delete, so a revoked +//! grant stays visible to [`list_grants`]. The database appends each changed +//! grant/revoke image to `agent_capability_events` atomically, retaining history +//! even after a re-grant replaces the current tombstone. +//! +//! Every function is community-scoped; a grant in one community never +//! authorizes anything in another. + +use crate::error::Result; +use crate::Db; +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; +use sqlx::{PgPool, Row}; + +/// The `cross_ssh` capability: execute a command on another machine. +pub const CAP_CROSS_SSH: &str = "cross_ssh"; + +/// Wildcard target: any machine in the community. +pub const TARGET_ANY: &str = "*"; + +/// A capability grant row, including revoked ones. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityGrant { + /// The agent the grant applies to. + pub agent_pubkey: Vec, + /// Capability name, e.g. [`CAP_CROSS_SSH`]. + pub capability: String, + /// Target machine id, or [`TARGET_ANY`]. + pub target: String, + /// Who granted it. + pub granted_by: Vec, + /// True when the grant has been revoked (tombstoned). + pub revoked: bool, +} + +/// Grant `capability` on `target` to `agent_pubkey`. +/// +/// Idempotent in effective permission. Each changed grant records a new history +/// fact; re-granting clears the current tombstone without erasing its history. +pub async fn grant( + pool: &PgPool, + community_id: CommunityId, + agent_pubkey: &[u8], + capability: &str, + target: &str, + granted_by: &[u8], +) -> Result<()> { + sqlx::query( + r#"INSERT INTO agent_capability_grants + (community_id, agent_pubkey, capability, target, granted_by) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (community_id, agent_pubkey, capability, target) + DO UPDATE SET revoked_at = NULL, + revoked_by = NULL, + granted_by = EXCLUDED.granted_by, + granted_at = NOW()"#, + ) + .bind(community_id.as_uuid()) + .bind(agent_pubkey) + .bind(capability) + .bind(target) + .bind(granted_by) + .execute(pool) + .await?; + Ok(()) +} + +/// Revoke a grant by tombstoning it. Revoking a nonexistent grant is a no-op +/// (the effective state — denied — is already correct). +pub async fn revoke( + pool: &PgPool, + community_id: CommunityId, + agent_pubkey: &[u8], + capability: &str, + target: &str, + revoked_by: &[u8], +) -> Result { + let result = sqlx::query( + r#"UPDATE agent_capability_grants + SET revoked_at = NOW(), revoked_by = $5 + WHERE community_id = $1 AND agent_pubkey = $2 + AND capability = $3 AND target = $4 + AND revoked_at IS NULL"#, + ) + .bind(community_id.as_uuid()) + .bind(agent_pubkey) + .bind(capability) + .bind(target) + .bind(revoked_by) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +/// The authorization check. **Default deny.** +/// +/// Returns `true` only when an active grant exists for the exact target or for +/// [`TARGET_ANY`]. No row, or a revoked row, means `false`. +pub async fn is_granted( + pool: &PgPool, + community_id: CommunityId, + agent_pubkey: &[u8], + capability: &str, + target: &str, +) -> Result { + let row = sqlx::query( + r#"SELECT EXISTS ( + SELECT 1 FROM agent_capability_grants + WHERE community_id = $1 AND agent_pubkey = $2 + AND capability = $3 AND (target = $4 OR target = '*') + AND revoked_at IS NULL + ) AS granted"#, + ) + .bind(community_id.as_uuid()) + .bind(agent_pubkey) + .bind(capability) + .bind(target) + .fetch_one(pool) + .await?; + Ok(row.get::("granted")) +} + +/// List every grant for a community, including revoked tombstones. +pub async fn list_grants(pool: &PgPool, community_id: CommunityId) -> Result> { + let rows = sqlx::query( + r#"SELECT agent_pubkey, capability, target, granted_by, + (revoked_at IS NOT NULL) AS revoked + FROM agent_capability_grants + WHERE community_id = $1 + ORDER BY granted_at DESC"#, + ) + .bind(community_id.as_uuid()) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|r| CapabilityGrant { + agent_pubkey: r.get("agent_pubkey"), + capability: r.get("capability"), + target: r.get("target"), + granted_by: r.get("granted_by"), + revoked: r.get("revoked"), + }) + .collect()) +} + +impl Db { + /// Returns `true` only if an active grant exists. Default deny. + #[datastore_span(name = "capability_is_granted", system = "postgresql")] + pub async fn capability_is_granted( + &self, + community_id: CommunityId, + agent_pubkey: &[u8], + capability: &str, + target: &str, + ) -> Result { + is_granted(&self.pool, community_id, agent_pubkey, capability, target).await + } + + /// Grant `capability` on `target` to `agent_pubkey`. Idempotent: re-granting + /// a revoked grant reactivates it. + #[datastore_span(name = "capability_grant", system = "postgresql")] + pub async fn capability_grant( + &self, + community_id: CommunityId, + agent_pubkey: &[u8], + capability: &str, + target: &str, + granted_by: &[u8], + ) -> Result<()> { + grant( + &self.pool, + community_id, + agent_pubkey, + capability, + target, + granted_by, + ) + .await + } + + /// Revoke a grant (tombstone). Returns `true` if an active grant was revoked. + #[datastore_span(name = "capability_revoke", system = "postgresql")] + pub async fn capability_revoke( + &self, + community_id: CommunityId, + agent_pubkey: &[u8], + capability: &str, + target: &str, + revoked_by: &[u8], + ) -> Result { + revoke( + &self.pool, + community_id, + agent_pubkey, + capability, + target, + revoked_by, + ) + .await + } + + /// List all grants (including revoked tombstones) for a community. + #[datastore_span(name = "capability_list_grants", system = "postgresql")] + pub async fn capability_list_grants( + &self, + community_id: CommunityId, + ) -> Result> { + list_grants(&self.pool, community_id).await + } +} + +#[cfg(test)] +mod postgres_tests; diff --git a/crates/buzz-db/src/store/agent_capability_grants/postgres_tests.rs b/crates/buzz-db/src/store/agent_capability_grants/postgres_tests.rs new file mode 100644 index 00000000000..d878f84468f --- /dev/null +++ b/crates/buzz-db/src/store/agent_capability_grants/postgres_tests.rs @@ -0,0 +1,492 @@ +use super::*; +use nostr::Keys; +use uuid::Uuid; + +async fn setup_pool() -> PgPool { + PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB") +} + +fn random_pubkey() -> Vec { + Keys::generate().public_key().to_bytes().to_vec() +} + +async fn make_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + let host = format!("cap-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) +} + +/// The PR's core assertion: with no grants at all, every check denies. +#[tokio::test] +#[ignore = "requires PostgreSQL"] +async fn test_default_deny_with_no_grants() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let agent = random_pubkey(); + + let allowed = is_granted(&pool, community, &agent, CAP_CROSS_SSH, "winnie-desktop") + .await + .expect("check"); + assert!(!allowed, "no grant must deny"); +} + +/// grant -> allow, revoke -> deny again. +#[tokio::test] +#[ignore = "requires PostgreSQL"] +async fn test_grant_then_revoke_round_trip() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let agent = random_pubkey(); + let admin = random_pubkey(); + + grant( + &pool, + community, + &agent, + CAP_CROSS_SSH, + "winnie-desktop", + &admin, + ) + .await + .expect("grant"); + assert!( + is_granted(&pool, community, &agent, CAP_CROSS_SSH, "winnie-desktop") + .await + .expect("check"), + "grant must allow" + ); + + revoke( + &pool, + community, + &agent, + CAP_CROSS_SSH, + "winnie-desktop", + &admin, + ) + .await + .expect("revoke"); + assert!( + !is_granted(&pool, community, &agent, CAP_CROSS_SSH, "winnie-desktop") + .await + .expect("check"), + "revoke must deny again" + ); +} + +/// A grant authorizes only the machine it names. +#[tokio::test] +#[ignore = "requires PostgreSQL"] +async fn test_grant_does_not_leak_to_other_targets() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let agent = random_pubkey(); + let admin = random_pubkey(); + + grant( + &pool, + community, + &agent, + CAP_CROSS_SSH, + "winnie-desktop", + &admin, + ) + .await + .expect("grant"); + + assert!( + !is_granted(&pool, community, &agent, CAP_CROSS_SSH, "rosie-pi") + .await + .expect("check"), + "grant on one machine must not authorize another" + ); +} + +/// A grant in one community never authorizes anything in another. +#[tokio::test] +#[ignore = "requires PostgreSQL"] +async fn test_grant_is_community_scoped() { + let pool = setup_pool().await; + let community_a = make_community(&pool).await; + let community_b = make_community(&pool).await; + let agent = random_pubkey(); + let admin = random_pubkey(); + + grant( + &pool, + community_a, + &agent, + CAP_CROSS_SSH, + "winnie-desktop", + &admin, + ) + .await + .expect("grant"); + + assert!( + !is_granted(&pool, community_b, &agent, CAP_CROSS_SSH, "winnie-desktop") + .await + .expect("check"), + "a grant must not cross community boundaries" + ); +} + +/// The wildcard target authorizes any machine in that community. +#[tokio::test] +#[ignore = "requires PostgreSQL"] +async fn test_wildcard_target_authorizes_any_machine() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let agent = random_pubkey(); + let admin = random_pubkey(); + + grant(&pool, community, &agent, CAP_CROSS_SSH, TARGET_ANY, &admin) + .await + .expect("grant"); + + assert!( + is_granted( + &pool, + community, + &agent, + CAP_CROSS_SSH, + "any-machine-at-all" + ) + .await + .expect("check"), + "wildcard must authorize an unnamed machine" + ); +} + +/// A different capability is not authorized by a cross_ssh grant. +#[tokio::test] +#[ignore = "requires PostgreSQL"] +async fn test_grant_does_not_leak_across_capabilities() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let agent = random_pubkey(); + let admin = random_pubkey(); + + grant( + &pool, + community, + &agent, + CAP_CROSS_SSH, + "winnie-desktop", + &admin, + ) + .await + .expect("grant"); + + assert!( + !is_granted(&pool, community, &agent, "read_secrets", "winnie-desktop") + .await + .expect("check"), + "cross_ssh must not authorize a different capability" + ); +} + +/// Re-granting a revoked pair clears the tombstone rather than duplicating. +#[tokio::test] +#[ignore = "requires PostgreSQL"] +async fn test_regrant_clears_tombstone() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let agent = random_pubkey(); + let admin = random_pubkey(); + + grant( + &pool, + community, + &agent, + CAP_CROSS_SSH, + "winnie-desktop", + &admin, + ) + .await + .expect("grant"); + revoke( + &pool, + community, + &agent, + CAP_CROSS_SSH, + "winnie-desktop", + &admin, + ) + .await + .expect("revoke"); + grant( + &pool, + community, + &agent, + CAP_CROSS_SSH, + "winnie-desktop", + &admin, + ) + .await + .expect("re-grant"); + + assert!( + is_granted(&pool, community, &agent, CAP_CROSS_SSH, "winnie-desktop") + .await + .expect("check"), + "re-grant must allow again" + ); + let grants = list_grants(&pool, community).await.expect("list"); + assert_eq!( + grants.len(), + 1, + "re-grant must reuse the row, not duplicate" + ); + assert!(!grants[0].revoked, "tombstone must be cleared"); +} + +/// Revoked grants stay listed, so the audit trail survives revocation. +#[tokio::test] +#[ignore = "requires PostgreSQL"] +async fn test_revoked_grants_remain_listed() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let agent = random_pubkey(); + let admin = random_pubkey(); + + grant( + &pool, + community, + &agent, + CAP_CROSS_SSH, + "winnie-desktop", + &admin, + ) + .await + .expect("grant"); + revoke( + &pool, + community, + &agent, + CAP_CROSS_SSH, + "winnie-desktop", + &admin, + ) + .await + .expect("revoke"); + + let grants = list_grants(&pool, community).await.expect("list"); + assert_eq!(grants.len(), 1, "revoked grant must remain visible"); + assert!(grants[0].revoked, "it must be marked revoked"); +} + +async fn history(pool: &PgPool, community: CommunityId) -> Vec { + sqlx::query_scalar( + "SELECT to_jsonb(e) FROM agent_capability_events e WHERE community_id = $1 ORDER BY id", + ) + .bind(community.as_uuid()) + .fetch_all(pool) + .await + .expect("history") +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn grant_revoke_regrant_preserves_actors_and_complete_images() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let agent = random_pubkey(); + let actors = [random_pubkey(), random_pubkey(), random_pubkey()]; + grant(&pool, community, &agent, CAP_CROSS_SSH, "mack", &actors[0]) + .await + .expect("grant"); + assert!( + revoke(&pool, community, &agent, CAP_CROSS_SSH, "mack", &actors[1]) + .await + .expect("revoke") + ); + assert!( + !revoke(&pool, community, &agent, CAP_CROSS_SSH, "mack", &actors[1]) + .await + .expect("repeat revoke") + ); + grant(&pool, community, &agent, CAP_CROSS_SSH, "mack", &actors[2]) + .await + .expect("regrant"); + let events = history(&pool, community).await; + assert_eq!(events.len(), 3); + for (index, action) in ["grant", "revoke", "grant"].into_iter().enumerate() { + assert_eq!(events[index]["action"], action); + assert_eq!( + events[index]["actor_pubkey"], + format!("\\x{}", hex::encode(&actors[index])) + ); + } + assert!(events[0]["before_state"].is_null()); + assert_eq!(events[1]["before_state"], events[0]["after_state"]); + assert_eq!(events[2]["before_state"], events[1]["after_state"]); + assert!(!events[1]["after_state"]["revoked_at"].is_null()); + assert!(events[2]["after_state"]["revoked_at"].is_null()); + assert!(is_granted(&pool, community, &agent, CAP_CROSS_SSH, "mack") + .await + .expect("current permission")); + let other = make_community(&pool).await; + assert!(history(&pool, other).await.is_empty()); + assert!(!is_granted(&pool, other, &agent, CAP_CROSS_SSH, "mack") + .await + .expect("tenant isolation")); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn history_failure_rolls_back_grant_and_revoke() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let agent = random_pubkey(); + let actor = random_pubkey(); + grant(&pool, community, &agent, CAP_CROSS_SSH, "mack", &actor) + .await + .expect("initial grant"); + let before = history(&pool, community).await; + sqlx::raw_sql("CREATE FUNCTION reject_capability_history_fixture() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected history write failure'; END $$; CREATE TRIGGER reject_history_fixture BEFORE INSERT ON agent_capability_events FOR EACH ROW EXECUTE FUNCTION reject_capability_history_fixture();") + .execute(&pool).await.expect("install failure at actual append seam"); + assert!( + revoke(&pool, community, &agent, CAP_CROSS_SSH, "mack", &actor) + .await + .is_err() + ); + assert!(is_granted(&pool, community, &agent, CAP_CROSS_SSH, "mack") + .await + .expect("revoke rollback")); + assert!( + grant(&pool, community, &agent, CAP_CROSS_SSH, "rosie", &actor) + .await + .is_err() + ); + assert!( + !is_granted(&pool, community, &agent, CAP_CROSS_SSH, "rosie") + .await + .expect("grant rollback") + ); + assert_eq!(history(&pool, community).await, before); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn concurrent_first_grants_have_one_serial_history_chain() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let agent = random_pubkey(); + let a = random_pubkey(); + let b = random_pubkey(); + let (first, second) = tokio::join!( + grant(&pool, community, &agent, CAP_CROSS_SSH, "mack", &a), + grant(&pool, community, &agent, CAP_CROSS_SSH, "mack", &b) + ); + first.expect("first grant"); + second.expect("second grant"); + let events = history(&pool, community).await; + assert_eq!(events.len(), 2); + assert!(events[0]["before_state"].is_null()); + assert_eq!(events[1]["before_state"], events[0]["after_state"]); + let grants = list_grants(&pool, community).await.expect("projection"); + assert_eq!( + events[1]["after_state"]["granted_by"], + format!("\\x{}", hex::encode(&grants[0].granted_by)) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn capability_history_cannot_be_rewritten_or_deleted_by_serving_writes() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + grant( + &pool, + community, + &random_pubkey(), + CAP_CROSS_SSH, + "mack", + &random_pubkey(), + ) + .await + .expect("grant"); + let before = history(&pool, community).await; + assert!(sqlx::query( + "UPDATE agent_capability_events SET action = 'revoke' WHERE community_id = $1" + ) + .bind(community.as_uuid()) + .execute(&pool) + .await + .is_err()); + assert!( + sqlx::query("DELETE FROM agent_capability_events WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .is_err() + ); + assert_eq!(history(&pool, community).await, before); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_upgrade_from_task_system_preserves_rows_and_adds_grant_history() { + let pool = setup_pool().await; + crate::migration::run_migrations_through(&pool, 46) + .await + .expect("deployed task schema"); + let community = make_community(&pool).await; + let task_id = Uuid::new_v4(); + sqlx::query("INSERT INTO tasks (community_id, id, title) VALUES ($1, $2, 'existing task')") + .bind(community.as_uuid()) + .bind(task_id) + .execute(&pool) + .await + .expect("old task"); + crate::migration::run_migrations(&pool) + .await + .expect("additive upgrade"); + let task = crate::task::get_task(&pool, community, task_id) + .await + .expect("preserved task"); + assert_eq!(task.title, "existing task"); + assert_eq!(task.revision, 0); + let agent = random_pubkey(); + crate::user::ensure_user(&pool, community, &agent) + .await + .expect("agent"); + crate::user::set_machine_home( + &pool, + community, + &agent, + &crate::user::MachineHome { + machine_id: "mack".into(), + machine_label: None, + machine_runtime: Some("hermes".into()), + }, + ) + .await + .expect("migrated home"); + assert!( + sqlx::query("UPDATE users SET machine_id = NULL WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) + .bind(&agent) + .execute(&pool) + .await + .is_err(), + "migration must also reject runtime metadata without a machine id" + ); + grant(&pool, community, &agent, CAP_CROSS_SSH, "mack", &agent) + .await + .expect("migrated grant"); + assert_eq!(history(&pool, community).await.len(), 1); + Db::from_pool(pool.clone()) + .validate_deletion_catalog() + .await + .expect("current deletion catalog"); +} diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index fc9c98a2131..9c2ddcd8ffe 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -40,88 +40,8 @@ pub const REDIS_STORE_NAME: &str = "redis"; /// against replicas still holding the old key during a rolling deploy. pub const SCHEMA_DESTRUCTION_LOCK_KEY: i64 = 0x62757a7a64656c31; -/// Control-plane tables that survive the community data purge. -pub const CONTROL_PLANE_TABLES: &[&str] = &[ - "community_deletion_approvals", - "community_deletion_checkpoints", - "community_deletion_executor_heartbeats", - "community_deletion_requests", - "community_serving_write_leases", -]; - -/// Expected community-scoped tables purged by V1. -/// -/// Catalog inventory compares the live database against this exact set before -/// approval and again before PostgreSQL purge. A new tenant table therefore -/// blocks deletion until this manifest is intentionally updated. -pub const EXPECTED_SCOPED_TABLES: &[&str] = &[ - "api_tokens", - "archived_identities", - "audit_log", - "channel_members", - "channels", - "community_bans", - "delivery_log", - "event_mentions", - "events", - "git_repo_names", - "join_policy_acceptances", - "moderation_actions", - "moderation_reports", - "parameterized_event_watermarks", - "pubkey_allowlist", - "push_leases", - "push_match_queue", - "push_wake_outbox", - "reactions", - "relay_invites", - "relay_members", - "scheduled_workflow_fires", - "subscriptions", - "task_events", - "tasks", - "thread_metadata", - "users", - "workflow_approvals", - "workflow_runs", - "workflows", -]; - -/// Foreign-key-safe child-before-parent order for the PostgreSQL purge. -pub const PURGE_SCOPED_TABLES: &[&str] = &[ - "workflow_approvals", - "scheduled_workflow_fires", - "workflow_runs", - "push_wake_outbox", - "join_policy_acceptances", - "moderation_reports", - "subscriptions", - // task_events → tasks (FK, cascading) and tasks → channels/users, so both - // must precede `channels` and `users` below. - "task_events", - "tasks", - "api_tokens", - "channel_members", - "thread_metadata", - "moderation_actions", - "workflows", - "event_mentions", - "reactions", - "push_match_queue", - "push_leases", - "relay_invites", - "delivery_log", - "events", - "parameterized_event_watermarks", - "git_repo_names", - "archived_identities", - "audit_log", - "community_bans", - "pubkey_allowlist", - "relay_members", - "users", - "channels", -]; +mod catalog; +pub use catalog::{CONTROL_PLANE_TABLES, EXPECTED_SCOPED_TABLES, PURGE_SCOPED_TABLES}; /// Fixed lifecycle order. There are no backwards or skipping transitions. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -4380,6 +4300,22 @@ mod postgres_tests { async fn checkpointed_resume_is_idempotent_and_tombstone_blocks_name_reuse() { let (db, store) = store().await; let (request, inventory) = inventoried_request(&db, &store).await; + crate::agent_capability_grants::grant( + &db.pool, + request.community_id, + &[9; 32], + "cross_ssh", + "mack", + &[8; 32], + ) + .await + .expect("grant with history before purge"); + let fleet = crate::store::fleet_attempt::fixtures::Fixture::in_community( + db.pool.clone(), + request.community_id, + ) + .await; + fleet.start().await; let host = request.community_host.clone(); let read_state_d_tag = format!("read-state:{}", "a".repeat(32)); sqlx::query( @@ -4465,6 +4401,9 @@ mod postgres_tests { .expect("bindings"); let first = store.purge_postgres(&token).await.expect("purge postgres"); assert_eq!(first.len(), EXPECTED_SCOPED_TABLES.len()); + assert_eq!(first["agent_capability_grants"], 2); + assert_eq!(first["agent_capability_events"], 2); + assert_eq!(first["fleet_attempts"], 1); assert!( store.purge_postgres(&token).await.is_err(), "completed stage cannot be replayed under stale checkpoint state" diff --git a/crates/buzz-db/src/store/deletion/catalog.rs b/crates/buzz-db/src/store/deletion/catalog.rs new file mode 100644 index 00000000000..dc786fa4dd6 --- /dev/null +++ b/crates/buzz-db/src/store/deletion/catalog.rs @@ -0,0 +1,92 @@ +/// Control-plane tables that survive the community data purge. +pub const CONTROL_PLANE_TABLES: &[&str] = &[ + "community_deletion_approvals", + "community_deletion_checkpoints", + "community_deletion_executor_heartbeats", + "community_deletion_requests", + "community_serving_write_leases", +]; + +/// Expected community-scoped tables purged by V1. +/// +/// Catalog inventory compares the live database against this exact set before +/// approval and again before PostgreSQL purge. A new tenant table therefore +/// blocks deletion until this manifest is intentionally updated. +pub const EXPECTED_SCOPED_TABLES: &[&str] = &[ + "agent_capability_events", + "agent_capability_grants", + "api_tokens", + "archived_identities", + "audit_log", + "channel_members", + "channels", + "community_bans", + "delivery_log", + "event_mentions", + "events", + "fleet_attempts", + "git_repo_names", + "join_policy_acceptances", + "machine_control_events", + "machines", + "moderation_actions", + "moderation_reports", + "parameterized_event_watermarks", + "pubkey_allowlist", + "push_leases", + "push_match_queue", + "push_wake_outbox", + "reactions", + "relay_invites", + "relay_members", + "scheduled_workflow_fires", + "subscriptions", + "task_events", + "tasks", + "thread_metadata", + "users", + "workflow_approvals", + "workflow_runs", + "workflows", +]; + +/// Foreign-key-safe child-before-parent order for the PostgreSQL purge. +pub const PURGE_SCOPED_TABLES: &[&str] = &[ + "machine_control_events", + "machines", + "fleet_attempts", + "agent_capability_events", + "agent_capability_grants", + "workflow_approvals", + "scheduled_workflow_fires", + "workflow_runs", + "push_wake_outbox", + "join_policy_acceptances", + "moderation_reports", + "subscriptions", + // task_events → tasks (FK, cascading) and tasks → channels/users, so both + // must precede `channels` and `users` below. + "task_events", + "tasks", + "api_tokens", + "channel_members", + "thread_metadata", + "moderation_actions", + "workflows", + "event_mentions", + "reactions", + "push_match_queue", + "push_leases", + "relay_invites", + "delivery_log", + "events", + "parameterized_event_watermarks", + "git_repo_names", + "archived_identities", + "audit_log", + "community_bans", + "pubkey_allowlist", + "relay_members", + "users", + "channels", +]; diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index cb45809eadb..4f9efcf044b 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -237,6 +237,11 @@ pub async fn huddle_started_links( if parent_channel_ids.is_empty() || ephemeral_channel_ids.is_empty() { return Ok(Vec::new()); } + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( r#" SELECT DISTINCT ON (backing.id) @@ -267,7 +272,7 @@ pub async fn huddle_started_links( .bind(KIND_HUDDLE_STARTED as i32) .bind(ephemeral_channel_ids) .bind(HUDDLE_LINK_CONTENT_MAX_BYTES) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() diff --git a/crates/buzz-db/src/store/fleet_attempt.rs b/crates/buzz-db/src/store/fleet_attempt.rs new file mode 100644 index 00000000000..7cb945d44c8 --- /dev/null +++ b/crates/buzz-db/src/store/fleet_attempt.rs @@ -0,0 +1,426 @@ +//! Atomic signed-event projection and primary-database execution admission. + +use buzz_core::{ + cml_event::{self, CmlTransition}, + fleet::{self, FleetReceipt, FleetScope, ReceiptStatus}, + CommunityId, StoredEvent, +}; +use buzz_datastore_tracing::datastore_span; +use nostr::Event; +use serde_json::{json, Value}; +use sqlx::{postgres::PgRow, Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::{ + event::ThreadMetadataParams, + observability::{acquire_writer, WriterOperation}, + Db, DbError, Result, +}; + +fn deny(message: impl Into) -> DbError { + DbError::AccessDenied(message.into()) +} +fn invalid(error: impl std::fmt::Display) -> DbError { + DbError::InvalidData(error.to_string()) +} + +async fn lock_task( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + task: Uuid, +) -> Result { + // Take the existing lifecycle lock before task, home, grant, or event rows. + let active: bool = sqlx::query_scalar("SELECT community_write_allowed($1)") + .bind(community.as_uuid()) + .fetch_one(&mut **tx) + .await?; + if !active { + return Err(deny("community does not admit fleet execution")); + } + sqlx::query("SELECT channel_id, revision, archived_at FROM tasks WHERE community_id=$1 AND id=$2 FOR UPDATE") + .bind(community.as_uuid()).bind(task).fetch_optional(&mut **tx).await? + .ok_or_else(|| deny("task unavailable")) +} + +/// Lock positive authorization evidence so revocation/home moves serialize with +/// admission. No permission cache or replica may be used by this path. +async fn lock_authority( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + channel: Uuid, + planner: &[u8], + worker: &[u8], + scope: &FleetScope, +) -> Result { + let now: i64 = sqlx::query_scalar("SELECT extract(epoch FROM clock_timestamp())::bigint") + .fetch_one(&mut **tx) + .await?; + if now < 0 || now as u64 >= scope.expires_at { + return Err(deny("fleet grant expired")); + } + let channel_live = sqlx::query("SELECT id FROM channels WHERE community_id=$1 AND id=$2 AND deleted_at IS NULL AND archived_at IS NULL FOR SHARE") + .bind(community.as_uuid()).bind(channel).fetch_optional(&mut **tx).await?; + if channel_live.is_none() { + return Err(deny("fleet channel unavailable")); + } + // An opt-in execution plan requires both participants to be explicit current + // channel members. Directory presence or an inherited display cache is not + // execution authority. + for key in [planner, worker] { + let user = sqlx::query("SELECT pubkey FROM users WHERE community_id=$1 AND pubkey=$2 AND deactivated_at IS NULL FOR SHARE") + .bind(community.as_uuid()).bind(key).fetch_optional(&mut **tx).await?; + let membership = sqlx::query("SELECT pubkey FROM channel_members WHERE community_id=$1 AND channel_id=$2 AND pubkey=$3 AND removed_at IS NULL FOR SHARE") + .bind(community.as_uuid()).bind(channel).bind(key).fetch_optional(&mut **tx).await?; + if user.is_none() || membership.is_none() { + return Err(deny("fleet participant is not an active channel member")); + } + } + let home = sqlx::query("SELECT pubkey FROM users WHERE community_id=$1 AND pubkey=$2 AND machine_id=$3 AND agent_type IS NOT NULL AND deactivated_at IS NULL FOR SHARE") + .bind(community.as_uuid()).bind(worker).bind(&scope.machine_id).fetch_optional(&mut **tx).await?; + if home.is_none() { + return Err(deny( + "fleet worker does not own the registered machine home", + )); + } + let grants = sqlx::query("SELECT to_jsonb(g) AS grant FROM agent_capability_grants g WHERE community_id=$1 AND agent_pubkey=$2 AND capability='cross_ssh' AND target IN ($3,'*') AND revoked_at IS NULL ORDER BY (target=$3) DESC FOR SHARE") + .bind(community.as_uuid()).bind(planner).bind(&scope.machine_id).fetch_all(&mut **tx).await?; + grants + .first() + .map(|row| row.get("grant")) + .ok_or_else(|| deny("planner has no active cross_ssh capability for this machine")) +} + +fn row_scope(row: &PgRow) -> Result { + serde_json::from_value(row.get("scope")).map_err(invalid) +} +fn row_event(row: &PgRow, column: &str) -> Result { + serde_json::from_value(row.get(column)).map_err(invalid) +} +fn same_revision(task: &PgRow, scope: &FleetScope) -> Result<()> { + if task + .get::>, _>("archived_at") + .is_some() + || task.get::("revision") != scope.task_revision + { + return Err(deny("task revision no longer matches approved plan")); + } + Ok(()) +} + +async fn project_cml( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + task: &PgRow, + event: &Event, +) -> Result<()> { + let cml = cml_event::validate_cml_event_after_signature(event).map_err(invalid)?; + let scope = FleetScope::from_task(&cml.task) + .map_err(invalid)? + .ok_or_else(|| invalid("fleet scope missing"))?; + if task.get::, _>("channel_id") != Some(cml.channel_id) { + return Err(deny("fleet plan must name its task's channel")); + } + let planner = hex::decode(&cml.task.roles.planner).map_err(invalid)?; + let worker = hex::decode( + cml.task + .roles + .worker + .as_deref() + .ok_or_else(|| deny("fleet worker required"))?, + ) + .map_err(invalid)?; + if cml.transition == CmlTransition::Plan { + same_revision(task, &scope)?; + if scope.expires_at <= cml.task.updated_at + || scope.expires_at > cml.task.updated_at.saturating_add(3600) + { + return Err(deny("fleet plan deadline must be within one hour")); + } + let grant = + lock_authority(tx, community, cml.channel_id, &planner, &worker, &scope).await?; + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM fleet_attempts WHERE community_id=$1 AND task_id=$2)", + ) + .bind(community.as_uuid()) + .bind(cml.task.id) + .fetch_one(&mut **tx) + .await?; + if exists { + return Err(deny( + "task already has a qualification attempt; automatic replacement is forbidden", + )); + } + let id = fleet::attempt_id(community, cml.task.id, event.id.as_bytes()); + let raw = serde_json::to_value(event)?; + sqlx::query("INSERT INTO fleet_attempts (community_id,id,task_id,channel_id,task_revision,plan_event_id,plan_event,cml_head,cml_event,planner_pubkey,worker_pubkey,machine_id,scope,state,permission_grants) VALUES ($1,$2,$3,$4,$5,$6,$7,$6,$7,$8,$9,$10,$11,'planned',$12)") + .bind(community.as_uuid()).bind(id).bind(cml.task.id).bind(cml.channel_id).bind(scope.task_revision).bind(event.id.as_bytes().as_slice()).bind(raw).bind(planner).bind(worker).bind(&scope.machine_id).bind(serde_json::to_value(&scope)?).bind(json!({"plan":grant})).execute(&mut **tx).await?; + return Ok(()); + } + let row = + sqlx::query("SELECT * FROM fleet_attempts WHERE community_id=$1 AND task_id=$2 FOR UPDATE") + .bind(community.as_uuid()) + .bind(cml.task.id) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| deny("fleet plan has no relay admission"))?; + let previous = cml_event::validate_cml_event_after_signature(&row_event(&row, "cml_event")?) + .map_err(invalid)?; + cml_event::validate_successor(&previous, &cml).map_err(|e| deny(e.to_string()))?; + if row_scope(&row)? != scope { + return Err(deny("fleet scope changed")); + } + let state: String = row.get("state"); + let mut next_state = state.clone(); + let mut grant = None; + if matches!(cml.transition, CmlTransition::Claim | CmlTransition::Start) { + let expected = if cml.transition == CmlTransition::Claim { + "planned" + } else { + "claimed" + }; + if state != expected || row.get::>, _>("cancel_event_id").is_some() { + return Err(deny("fleet attempt cannot claim or start again")); + } + same_revision(task, &scope)?; + grant = + Some(lock_authority(tx, community, cml.channel_id, &planner, &worker, &scope).await?); + let lease = cml + .task + .lease + .as_ref() + .ok_or_else(|| deny("fleet lease required"))?; + if lease.id != row.get::("id") || lease.expires_at != scope.expires_at { + return Err(deny("fleet lease binding mismatch")); + } + next_state = if cml.transition == CmlTransition::Claim { + "claimed" + } else { + "started" + } + .into(); + } else if cml.transition == CmlTransition::LeaseExpired { + let now: i64 = sqlx::query_scalar("SELECT extract(epoch FROM clock_timestamp())::bigint") + .fetch_one(&mut **tx) + .await?; + if now < 0 || (now as u64) < scope.expires_at { + return Err(deny("fleet lease has not expired")); + } + if state != "claimed" { + return Err(deny("started fleet attempts are never re-leased")); + } + next_state = "expired".into(); + } else if cml.transition == CmlTransition::Submit { + if state != "success" { + return Err(deny( + "worker submission requires a durable successful receipt", + )); + } + let signed_receipt = row_event(&row, "receipt")?; + let (_, receipt) = + FleetReceipt::from_event_after_signature(&signed_receipt).map_err(invalid)?; + let receipt_id = signed_receipt.id.to_hex(); + if row.get::>, _>("receipt_event_id").as_deref() + != Some(signed_receipt.id.as_bytes().as_slice()) + || receipt.status != ReceiptStatus::Success + || receipt.qualification.as_ref().map(|q| &q.head_sha) != cml.task.git.head_sha.as_ref() + || !cml.task.evidence.iter().any(|evidence| { + evidence.kind == "fleet-qualification-receipt" && evidence.reference == receipt_id + }) + { + return Err(deny( + "worker submission must bind the observed commit and signed receipt", + )); + } + } else if cml.transition == CmlTransition::Block + && !matches!(state.as_str(), "error" | "cancelled") + { + return Err(deny("worker blocker requires a durable terminal receipt")); + } + let mut permissions: Value = row.get("permission_grants"); + if let Some(grant) = grant { + permissions[if cml.transition == CmlTransition::Claim { + "claim" + } else { + "start" + }] = grant; + } + sqlx::query("UPDATE fleet_attempts SET cml_head=$3,cml_event=$4,state=$5,permission_grants=$6,claim_event_id=CASE WHEN $7 THEN $3 ELSE claim_event_id END,start_event_id=CASE WHEN $8 THEN $3 ELSE start_event_id END,cancel_event_id=CASE WHEN $9 THEN $3 ELSE cancel_event_id END,updated_at=clock_timestamp() WHERE community_id=$1 AND task_id=$2") + .bind(community.as_uuid()).bind(cml.task.id).bind(event.id.as_bytes().as_slice()).bind(serde_json::to_value(event)?).bind(next_state).bind(permissions) + .bind(cml.transition == CmlTransition::Claim).bind(cml.transition == CmlTransition::Start).bind(cml.transition == CmlTransition::Cancel).execute(&mut **tx).await?; + Ok(()) +} + +async fn project_receipt( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + task: &PgRow, + event: &Event, +) -> Result<()> { + let (channel, receipt) = FleetReceipt::from_event_after_signature(event).map_err(invalid)?; + let row = sqlx::query( + "SELECT * FROM fleet_attempts WHERE community_id=$1 AND task_id=$2 AND id=$3 FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(receipt.task_id) + .bind(&receipt.attempt_id) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| deny("fleet attempt not found"))?; + let scope = row_scope(&row)?; + let start: Option> = row.get("start_event_id"); + if task.get::, _>("channel_id") != Some(channel) + || channel != row.get::("channel_id") + || event.pubkey.to_bytes().as_slice() != row.get::, _>("worker_pubkey") + || receipt.plan_event_id != hex::encode(row.get::, _>("plan_event_id")) + || receipt.start_event_id != start.as_ref().map(hex::encode) + || receipt.policy_digest != scope.policy_digest + || receipt.machine_id != scope.machine_id + { + return Err(deny("fleet receipt scope/worker/start mismatch")); + } + let state: String = row.get("state"); + if matches!( + state.as_str(), + "success" | "error" | "cancelled" | "cancelled_before_execution" | "expired" + ) { + return Err(deny("fleet attempt already has a terminal outcome")); + } + let cancelled = row.get::>, _>("cancel_event_id").is_some(); + if matches!( + receipt.status, + ReceiptStatus::Cancelled | ReceiptStatus::CancelledBeforeExecution + ) && !cancelled + { + return Err(deny( + "stop acknowledgement requires signed planner cancellation", + )); + } + if receipt.status == ReceiptStatus::CancelledBeforeExecution { + if start.is_some() || !matches!(state.as_str(), "planned" | "claimed") { + return Err(deny("attempt already has execution admission")); + } + } else if start.is_none() || !matches!(state.as_str(), "started" | "unknown") { + return Err(deny("receipt has no matching execution admission")); + } + if let Some(q) = &receipt.qualification { + let plan = cml_event::validate_cml_event_after_signature(&row_event(&row, "plan_event")?) + .map_err(invalid)?; + if q.repository != plan.task.git.repo { + return Err(deny("receipt repository mismatch")); + } + } + let next = match receipt.status { + ReceiptStatus::Success => "success", + ReceiptStatus::Error => "error", + ReceiptStatus::Cancelled => "cancelled", + ReceiptStatus::CancelledBeforeExecution => "cancelled_before_execution", + ReceiptStatus::Unknown => "unknown", + }; + sqlx::query("UPDATE fleet_attempts SET state=$3,receipt_event_id=$4,receipt=$5,updated_at=clock_timestamp() WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()).bind(&receipt.attempt_id).bind(next).bind(event.id.as_bytes().as_slice()).bind(serde_json::to_value(event)?).execute(&mut **tx).await?; + Ok(()) +} + +impl Db { + /// Persist a signature-verified fleet event and its projection atomically. + /// The transport-neutral ingest pipeline must run its ordinary admission, + /// scope, membership, moderation and thread checks before calling this seam. + #[datastore_span(name = "insert_fleet_event", system = "postgresql")] + pub async fn insert_fleet_event( + &self, + community: CommunityId, + event: &Event, + channel: Option, + thread: Option>, + ) -> Result<(StoredEvent, bool)> { + let task_id = if fleet::is_receipt(event) { + FleetReceipt::from_event_after_signature(event) + .map_err(invalid)? + .1 + .task_id + } else { + cml_event::validate_cml_event_after_signature(event) + .map_err(invalid)? + .task + .id + }; + let connection = acquire_writer(&self.pool, WriterOperation::EventWrite).await?; + let mut tx = Transaction::begin(connection, None).await?; + let task = lock_task(&mut tx, community, task_id).await?; + let result = crate::event::insert_event_with_thread_metadata_tx( + &mut tx, community, event, channel, thread, + ) + .await?; + if result.1 { + if fleet::is_receipt(event) { + project_receipt(&mut tx, community, &task, event).await?; + } else { + project_cml(&mut tx, community, &task, event).await?; + } + } + tx.commit().await?; + Ok(result) + } + + /// Display/audit facts only. This response can never authorize a spawn. + #[datastore_span(name = "list_task_attempts", system = "postgresql")] + pub async fn list_task_attempts( + &self, + community: CommunityId, + task: Uuid, + ) -> Result> { + let mut connection = acquire_writer(&self.pool, WriterOperation::EventWrite).await?; + let rows = sqlx::query("SELECT * FROM fleet_attempts WHERE community_id=$1 AND task_id=$2 ORDER BY created_at,id") + .bind(community.as_uuid()).bind(task).fetch_all(&mut *connection).await?; + Ok(rows.iter().map(|r| json!({"id":r.get::("id"),"task_id":task,"plan_event_id":hex::encode(r.get::,_>("plan_event_id")),"worker":hex::encode(r.get::,_>("worker_pubkey")),"machine_id":r.get::("machine_id"),"state":r.get::("state"),"start_event_id":r.get::>,_>("start_event_id").map(hex::encode),"cancel_event_id":r.get::>,_>("cancel_event_id").map(hex::encode),"receipt_event_id":r.get::>,_>("receipt_event_id").map(hex::encode),"receipt":r.get::,_>("receipt")})).collect()) + } + + /// Fresh primary-database check of a particular newly accepted start. + /// This is separate from display reads. Callers must additionally possess + /// their own accepted-new start response; replayed/lost responses fail shut. + #[datastore_span(name = "fleet_start_admission", system = "postgresql")] + pub async fn fleet_start_admission( + &self, + community: CommunityId, + task_id: Uuid, + attempt: &str, + start: &[u8], + worker: &[u8], + ) -> Result { + let connection = acquire_writer(&self.pool, WriterOperation::Authorization).await?; + let mut tx = Transaction::begin(connection, None).await?; + let task = lock_task(&mut tx, community, task_id).await?; + let row = sqlx::query( + "SELECT * FROM fleet_attempts WHERE community_id=$1 AND id=$2 AND task_id=$3 FOR SHARE", + ) + .bind(community.as_uuid()) + .bind(attempt) + .bind(task_id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| deny("fleet admission not found"))?; + let scope = row_scope(&row)?; + if row.get::("state") != "started" + || row.get::>, _>("cancel_event_id").is_some() + || row.get::>, _>("start_event_id").as_deref() != Some(start) + || row.get::, _>("worker_pubkey").as_slice() != worker + { + return Err(deny("fleet execution admission unavailable")); + } + same_revision(&task, &scope)?; + let planner: Vec = row.get("planner_pubkey"); + let channel: Uuid = row.get("channel_id"); + if task.get::, _>("channel_id") != Some(channel) { + return Err(deny("task channel changed")); + } + lock_authority(&mut tx, community, channel, &planner, worker, &scope).await?; + let result = json!({"attempt_id":attempt,"task_id":task_id,"plan_event_id":hex::encode(row.get::,_>("plan_event_id")),"start_event_id":hex::encode(start),"worker":hex::encode(worker),"machine_id":scope.machine_id,"policy_digest":scope.policy_digest,"expires_at":scope.expires_at}); + tx.commit().await?; + Ok(result) + } +} + +#[cfg(test)] +pub(crate) mod fixtures; +#[cfg(test)] +mod postgres_tests; diff --git a/crates/buzz-db/src/store/fleet_attempt/fixtures.rs b/crates/buzz-db/src/store/fleet_attempt/fixtures.rs new file mode 100644 index 00000000000..18ca2fdaad9 --- /dev/null +++ b/crates/buzz-db/src/store/fleet_attempt/fixtures.rs @@ -0,0 +1,233 @@ +use super::*; +use buzz_core::{ + channel::{ChannelType, ChannelVisibility}, + cml::{CmlStatus, CmlTask, Lease}, + cml_event::CmlRole, +}; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use sqlx::PgPool; + +pub(crate) struct Fixture { + pub db: Db, + pub pool: PgPool, + pub community: CommunityId, + pub channel: Uuid, + pub planner: Keys, + pub worker: Keys, + pub task: CmlTask, +} + +impl Fixture { + pub async fn new() -> Self { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let community = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities(id,host) VALUES($1,$2)") + .bind(community.as_uuid()) + .bind(format!("fleet-{}.invalid", community.as_uuid())) + .execute(&pool) + .await + .unwrap(); + Self::in_community(pool, community).await + } + + pub async fn in_community(pool: PgPool, community: CommunityId) -> Self { + let db = Db::from_pool(pool.clone()); + let planner = Keys::generate(); + let worker = Keys::generate(); + for keys in [&planner, &worker] { + db.ensure_user(community, &keys.public_key().to_bytes()) + .await + .unwrap(); + } + sqlx::query("UPDATE users SET machine_id='mack',agent_type='hermes' WHERE community_id=$1 AND pubkey=$2") + .bind(community.as_uuid()).bind(worker.public_key().to_bytes().as_slice()).execute(&pool).await.unwrap(); + let channel = db + .create_channel( + community, + "Fleet qualification", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &planner.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + sqlx::query("INSERT INTO channel_members(community_id,channel_id,pubkey,role) VALUES($1,$2,$3,'bot')") + .bind(community.as_uuid()).bind(channel).bind(worker.public_key().to_bytes().as_slice()).execute(&pool).await.unwrap(); + let row = db + .create_task( + community, + crate::task::NewTask { + title: "Qualify repository".into(), + channel_id: Some(channel), + created_by_pubkey: Some(planner.public_key().to_bytes().to_vec()), + ..Default::default() + }, + ) + .await + .unwrap(); + let now = Timestamp::now().as_secs(); + let task = serde_json::from_value(json!({ + "protocol":"buzz-cml","version":1,"id":row.id,"title":"Qualify repository", + "objective":"Observe tracked files","status":"planned","priority":"P2","updated_at":now, + "roles":{"planner":planner.public_key().to_hex(),"worker":worker.public_key().to_hex(),"reviewer":Keys::generate().public_key().to_hex(),"fixer":null}, + "git":{"repo":"mfethe1/buzz","branch":"codex/qualification","base_sha":"a".repeat(40),"head_sha":null,"worktree_alias":"qualification"}, + "lease":null,"evidence":[],"blockers":[],"acceptance":[],"review":{"round":0,"max_rounds":3}, + "runtime":{"host_id":null,"last_heartbeat_at":null,"presence":"offline","ttl_seconds":180}, + "extensions":{fleet::EXTENSION:{"target":"mack","machine_id":"mack","repository":"buzz","capability":"qualify","expires_at":now+300,"task_revision":row.revision,"policy_digest":"b".repeat(64)}} + })).unwrap(); + Self { + db, + pool, + community, + channel, + planner, + worker, + task, + } + } + pub async fn grant(&self) { + crate::agent_capability_grants::grant( + &self.pool, + self.community, + &self.planner.public_key().to_bytes(), + "cross_ssh", + "mack", + &self.planner.public_key().to_bytes(), + ) + .await + .unwrap(); + } + pub fn event( + &self, + task: &CmlTask, + transition: CmlTransition, + previous: Option<&Event>, + ) -> Event { + let role = if matches!( + transition, + CmlTransition::Plan | CmlTransition::Cancel | CmlTransition::LeaseExpired + ) { + CmlRole::Planner + } else { + CmlRole::Worker + }; + let keys = if role == CmlRole::Planner { + &self.planner + } else { + &self.worker + }; + let status = serde_json::to_value(task.status) + .unwrap() + .as_str() + .unwrap() + .to_owned(); + let mut tags = vec![ + Tag::parse(["h", &self.channel.to_string()]).unwrap(), + Tag::parse(["d", &task.id.to_string()]).unwrap(), + Tag::parse(["protocol", "buzz-cml", "1"]).unwrap(), + Tag::parse(["transition", transition.as_str()]).unwrap(), + Tag::parse(["status", &status]).unwrap(), + Tag::parse(["role", role.as_str()]).unwrap(), + ]; + if let Some(previous) = previous { + tags.push(Tag::parse(["e", &previous.id.to_hex(), "prev"]).unwrap()); + } + let event = EventBuilder::new( + Kind::Custom(transition.event_kind() as u16), + task.to_canonical_json().unwrap(), + ) + .tags(tags) + .custom_created_at(Timestamp::from(task.updated_at)) + .sign_with_keys(keys) + .unwrap(); + cml_event::validate_cml_event(&event).unwrap(); + event + } + pub async fn persist(&self, event: &Event) -> Result<(StoredEvent, bool)> { + self.db + .insert_fleet_event(self.community, event, Some(self.channel), None) + .await + } + pub async fn plan_claim(&self) -> (Event, Event, CmlTask) { + self.grant().await; + let plan = self.event(&self.task, CmlTransition::Plan, None); + assert!(self.persist(&plan).await.unwrap().1); + let mut claimed = self.task.clone(); + claimed.status = CmlStatus::Claimed; + claimed.lease = Some(Lease { + id: fleet::attempt_id(self.community, self.task.id, plan.id.as_bytes()), + holder: self.worker.public_key().to_hex(), + issued_at: self.task.updated_at, + expires_at: self.task.updated_at + 300, + }); + let claim = self.event(&claimed, CmlTransition::Claim, Some(&plan)); + assert!(self.persist(&claim).await.unwrap().1); + (plan, claim, claimed) + } + pub async fn start(&self) -> (Event, Event, CmlTask) { + let (plan, claim, mut task) = self.plan_claim().await; + task.status = CmlStatus::Working; + let start = self.event(&task, CmlTransition::Start, Some(&claim)); + assert!(self.persist(&start).await.unwrap().1); + (plan, start, task) + } + pub fn receipt(&self, plan: &Event, start: Option<&Event>, status: ReceiptStatus) -> Event { + let body = FleetReceipt { + attempt_id: fleet::attempt_id(self.community, self.task.id, plan.id.as_bytes()), + task_id: self.task.id, + plan_event_id: plan.id.to_hex(), + start_event_id: start.map(|e| e.id.to_hex()), + machine_id: "mack".into(), + policy_digest: "b".repeat(64), + status, + qualification: if status == ReceiptStatus::Success { + Some(fleet::Qualification { + repository: "mfethe1/buzz".into(), + head_sha: "c".repeat(40), + tracked_files: 3, + python: "3.14.0".into(), + }) + } else { + None + }, + error: None, + completed_at: Timestamp::now().as_secs(), + }; + EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_JOB_RESULT as u16), + body.to_canonical_json().unwrap(), + ) + .tags([ + Tag::parse(["protocol", fleet::RECEIPT_PROTOCOL, "1"]).unwrap(), + Tag::parse(["h", &self.channel.to_string()]).unwrap(), + Tag::parse(["d", &body.attempt_id]).unwrap(), + ]) + .custom_created_at(Timestamp::from(body.completed_at)) + .sign_with_keys(&self.worker) + .unwrap() + } + pub async fn admission(&self, plan: &Event, start: &Event) -> Result { + self.db + .fleet_start_admission( + self.community, + self.task.id, + &fleet::attempt_id(self.community, self.task.id, plan.id.as_bytes()), + start.id.as_bytes(), + &self.worker.public_key().to_bytes(), + ) + .await + } + pub async fn event_count(&self, event: &Event) -> i64 { + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2") + .bind(self.community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&self.pool) + .await + .unwrap() + } +} diff --git a/crates/buzz-db/src/store/fleet_attempt/postgres_tests.rs b/crates/buzz-db/src/store/fleet_attempt/postgres_tests.rs new file mode 100644 index 00000000000..54c390b4cf7 --- /dev/null +++ b/crates/buzz-db/src/store/fleet_attempt/postgres_tests.rs @@ -0,0 +1,262 @@ +use super::fixtures::Fixture; +use super::*; +use buzz_core::cml::CmlStatus; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn submit_binds_observed_commit_and_exact_signed_receipt() { + let f = Fixture::new().await; + let (plan, start, mut task) = f.start().await; + let receipt = f.receipt(&plan, Some(&start), ReceiptStatus::Success); + f.persist(&receipt).await.unwrap(); + task.status = CmlStatus::Review; + task.git.head_sha = Some("c".repeat(40)); + task.evidence.push(buzz_core::cml::Evidence { + kind: "fleet-qualification-receipt".into(), + reference: receipt.id.to_hex(), + }); + for case in [ + "wrong_commit", + "missing_receipt", + "wrong_receipt", + "wrong_kind", + ] { + let mut invalid = task.clone(); + match case { + "wrong_commit" => invalid.git.head_sha = Some("d".repeat(40)), + "missing_receipt" => invalid.evidence.clear(), + "wrong_receipt" => invalid.evidence[0].reference = "e".repeat(64), + "wrong_kind" => invalid.evidence[0].kind = "test".into(), + _ => unreachable!(), + } + let event = f.event(&invalid, CmlTransition::Submit, Some(&start)); + assert!(f.persist(&event).await.is_err(), "accepted {case}"); + assert_eq!(f.event_count(&event).await, 0, "stored {case}"); + let head: Vec = sqlx::query_scalar( + "SELECT cml_head FROM fleet_attempts WHERE community_id=$1 AND task_id=$2", + ) + .bind(f.community.as_uuid()) + .bind(task.id) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!(head, start.id.as_bytes().as_slice()); + } + let valid = f.event(&task, CmlTransition::Submit, Some(&start)); + assert!(f.persist(&valid).await.unwrap().1); + assert!(!f.persist(&valid).await.unwrap().1); + assert_eq!(f.event_count(&valid).await, 1); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn atomic_signed_plan_start_receipt_and_duplicate_round_trip() { + let f = Fixture::new().await; + let (plan, claim, mut task) = f.plan_claim().await; + task.status = CmlStatus::Working; + let start = f.event(&task, CmlTransition::Start, Some(&claim)); + let (one, two) = tokio::join!(f.persist(&start), f.persist(&start)); + assert_eq!( + [one.unwrap().1, two.unwrap().1] + .iter() + .filter(|&&v| v) + .count(), + 1 + ); + assert_eq!(f.event_count(&start).await, 1); + assert_eq!( + f.admission(&plan, &start).await.unwrap()["start_event_id"], + start.id.to_hex() + ); + let receipt = f.receipt(&plan, Some(&start), ReceiptStatus::Success); + assert!(f.persist(&receipt).await.unwrap().1); + assert!(!f.persist(&receipt).await.unwrap().1); + let display = + f.db.list_task_attempts(f.community, f.task.id) + .await + .unwrap(); + assert_eq!(display.len(), 1); + assert_eq!(display[0]["state"], "success"); + assert_eq!(display[0]["receipt_event_id"], receipt.id.to_hex()); + assert_eq!(display[0]["receipt"]["id"], receipt.id.to_hex()); + assert!(f.admission(&plan, &start).await.is_err()); + f.db.validate_deletion_catalog().await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn ungranted_plan_rolls_back_signed_event_and_projection() { + let f = Fixture::new().await; + let plan = f.event(&f.task, CmlTransition::Plan, None); + assert!(f.persist(&plan).await.is_err()); + assert_eq!(f.event_count(&plan).await, 0); + assert!(f + .db + .list_task_attempts(f.community, f.task.id) + .await + .unwrap() + .is_empty()); + f.grant().await; + assert!(f.persist(&plan).await.unwrap().1); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn revoked_grant_serializes_before_start_and_rolls_back_event() { + let f = Fixture::new().await; + let (_, claim, mut task) = f.plan_claim().await; + task.status = CmlStatus::Working; + let start = f.event(&task, CmlTransition::Start, Some(&claim)); + let mut revocation = f.pool.begin().await.unwrap(); + sqlx::query("UPDATE agent_capability_grants SET revoked_at=clock_timestamp(),revoked_by=agent_pubkey WHERE community_id=$1") + .bind(f.community.as_uuid()).execute(&mut *revocation).await.unwrap(); + let pending = f.persist(&start); + tokio::pin!(pending); + // The actual start is blocked on the positive grant row; the committed + // revoke must then be re-evaluated, not trusted from an earlier snapshot. + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), &mut pending) + .await + .is_err() + ); + revocation.commit().await.unwrap(); + assert!(pending.await.is_err()); + assert_eq!(f.event_count(&start).await, 0); + assert_eq!( + f.db.list_task_attempts(f.community, f.task.id) + .await + .unwrap()[0]["state"], + "claimed" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn primary_admission_rechecks_grants_revision_home_membership_and_worker() { + let f = Fixture::new().await; + let (plan, start, _) = f.start().await; + assert!(f.admission(&plan, &start).await.is_ok()); + let id = fleet::attempt_id(f.community, f.task.id, plan.id.as_bytes()); + assert!(f + .db + .fleet_start_admission( + f.community, + f.task.id, + &id, + start.id.as_bytes(), + &f.planner.public_key().to_bytes() + ) + .await + .is_err()); + for statement in [ + "UPDATE agent_capability_grants SET revoked_at=clock_timestamp(),revoked_by=agent_pubkey WHERE community_id=$1", + "UPDATE users SET machine_id='rosie' WHERE community_id=$1 AND agent_type IS NOT NULL", + "UPDATE channel_members SET removed_at=clock_timestamp() WHERE community_id=$1 AND role='bot'", + "UPDATE tasks SET title='changed approved task' WHERE community_id=$1", + "UPDATE channels SET archived_at=clock_timestamp() WHERE community_id=$1", + "UPDATE users SET deactivated_at=clock_timestamp() WHERE community_id=$1 AND agent_type IS NOT NULL", + ] { + let other=Fixture::new().await;let (p,s,_)=other.start().await; + sqlx::query(sqlx::AssertSqlSafe(statement)).bind(other.community.as_uuid()).execute(&other.pool).await.unwrap(); + assert!(other.admission(&p,&s).await.is_err(),"guard failed for {statement}"); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn cancellation_is_only_intent_until_worker_terminal_receipt() { + let f = Fixture::new().await; + let (plan, start, mut task) = f.start().await; + task.status = CmlStatus::Cancelled; + task.lease = None; + let cancel = f.event(&task, CmlTransition::Cancel, Some(&start)); + f.persist(&cancel).await.unwrap(); + let display = + f.db.list_task_attempts(f.community, f.task.id) + .await + .unwrap(); + assert_eq!(display[0]["state"], "started"); + assert!(display[0]["receipt_event_id"].is_null()); + assert!(f.admission(&plan, &start).await.is_err()); + let unknown = f.receipt(&plan, Some(&start), ReceiptStatus::Unknown); + f.persist(&unknown).await.unwrap(); + assert_eq!( + f.db.list_task_attempts(f.community, f.task.id) + .await + .unwrap()[0]["state"], + "unknown" + ); + let receipt = f.receipt(&plan, Some(&start), ReceiptStatus::Cancelled); + f.persist(&receipt).await.unwrap(); + assert_eq!( + f.db.list_task_attempts(f.community, f.task.id) + .await + .unwrap()[0]["state"], + "cancelled" + ); + let mut retry = task.clone(); + retry.status = CmlStatus::Claimed; + retry.lease = Some(buzz_core::cml::Lease { + id: fleet::attempt_id(f.community, f.task.id, plan.id.as_bytes()), + holder: f.worker.public_key().to_hex(), + issued_at: retry.updated_at, + expires_at: retry.updated_at + 300, + }); + let retry = f.event(&retry, CmlTransition::Claim, Some(&cancel)); + assert!(f.persist(&retry).await.is_err()); + assert_eq!(f.event_count(&retry).await, 0); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn wrong_tenant_and_worker_receipts_do_not_modify_attempt() { + let f = Fixture::new().await; + let (plan, start, _) = f.start().await; + let wrong = Fixture::new().await; + let receipt = f.receipt(&plan, Some(&start), ReceiptStatus::Success); + assert!(wrong + .db + .insert_fleet_event(wrong.community, &receipt, Some(f.channel), None) + .await + .is_err()); + let forged = nostr::EventBuilder::new(receipt.kind, receipt.content.clone()) + .tags(receipt.tags.clone()) + .custom_created_at(receipt.created_at) + .sign_with_keys(&f.planner) + .unwrap(); + assert!(f.persist(&forged).await.is_err()); + assert_eq!(f.event_count(&forged).await, 0); + assert_eq!( + f.db.list_task_attempts(f.community, f.task.id) + .await + .unwrap()[0]["state"], + "started" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_upgrade_keeps_existing_events_but_never_backfills_execution_authority() { + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + crate::migration::run_migrations_through(&pool, 50) + .await + .unwrap(); + let f = Fixture::new().await; + f.grant().await; + let plan = f.event(&f.task, CmlTransition::Plan, None); + f.db.insert_event_with_thread_metadata(f.community, &plan, Some(f.channel), None) + .await + .unwrap(); + crate::migration::run_migrations(&pool).await.unwrap(); + assert_eq!(f.event_count(&plan).await, 1); + assert!(!f.persist(&plan).await.unwrap().1); + assert!(f + .db + .list_task_attempts(f.community, f.task.id) + .await + .unwrap() + .is_empty()); + f.db.validate_deletion_catalog().await.unwrap(); +} diff --git a/crates/buzz-db/src/store/machine.rs b/crates/buzz-db/src/store/machine.rs new file mode 100644 index 00000000000..3602e8be911 --- /dev/null +++ b/crates/buzz-db/src/store/machine.rs @@ -0,0 +1,220 @@ +//! Private signed machine journal and immutable enrollment projection. + +use buzz_core::{machine::*, CommunityId}; +use buzz_datastore_tracing::datastore_span; +use nostr::Event; +use serde_json::{json, Value}; +use sqlx::{Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::{ + observability::{acquire_writer, WriterOperation}, + Db, DbError, Result, +}; + +fn denied() -> DbError { + DbError::AccessDenied("machine registration or coordinator unavailable".into()) +} + +impl Db { + /// Persist a verified command and its projection in one primary transaction. + /// The shared ingest handler must verify the event, owner proof, transport + /// identity, scope and moderation before this private-store seam is reached. + #[datastore_span(name = "apply_machine_command", system = "postgresql")] + pub async fn apply_machine_command( + &self, + community: CommunityId, + event: &Event, + ) -> Result { + let command = + MachineCommand::from_event_after_signature(event).map_err(DbError::InvalidData)?; + if command.community_id() != *community.as_uuid() { + return Err(denied()); + } + let connection = acquire_writer(&self.pool, WriterOperation::EventWrite).await?; + let mut tx = Transaction::begin(connection, None).await?; + let active: bool = sqlx::query_scalar("SELECT community_write_allowed($1)") + .bind(community.as_uuid()) + .fetch_one(&mut *tx) + .await?; + if !active { + return Err(denied()); + } + // Serialize absent-row enrollment and duplicate admission for the same + // tenant/machine. Collisions only serialize; they never grant access. + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!( + "machine:{}:{}", + community.as_uuid(), + command.machine_id() + )) + .execute(&mut *tx) + .await?; + let duplicate: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM machine_control_events WHERE community_id=$1 AND event_id=$2)") + .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()).fetch_one(&mut *tx).await?; + if duplicate { + return Ok(false); + } + let owner = match &command { + MachineCommand::Enroll(enrollment) => { + enroll(&mut tx, community, event, enrollment).await? + } + MachineCommand::Observe(observation) => { + observe(&mut tx, community, event, observation).await? + } + }; + sqlx::query("INSERT INTO machine_control_events(community_id,event_id,machine_id,owner_pubkey,kind,signed_event) VALUES($1,$2,$3,$4,$5,$6)") + .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()).bind(command.machine_id()) + .bind(owner).bind(i32::from(event.kind.as_u16())).bind(serde_json::to_value(event)?).execute(&mut *tx).await?; + tx.commit().await?; + Ok(true) + } + + /// Read only the authenticated owner's machines, filtering before pagination. + /// Always reads the primary; observations are freshness-bounded display data. + #[datastore_span(name = "list_machines", system = "postgresql")] + pub async fn list_machines( + &self, + community: CommunityId, + owner: &[u8], + after: Option, + limit: i64, + ) -> Result> { + if !(1..=101).contains(&limit) { + return Err(DbError::InvalidData("invalid machine limit".into())); + } + let rows = sqlx::query("SELECT m.*, statement_timestamp() AS server_now, e.signed_event AS enrollment, o.signed_event AS observation, (m.expires_at > statement_timestamp() AND EXISTS(SELECT 1 FROM users u JOIN users owner ON owner.community_id=u.community_id AND owner.pubkey=m.owner_pubkey WHERE u.community_id=m.community_id AND u.pubkey=m.coordinator_pubkey AND u.agent_owner_pubkey=m.owner_pubkey AND u.machine_id=m.machine_id::text AND u.agent_type=m.runtime AND u.deactivated_at IS NULL AND owner.deactivated_at IS NULL)) AS fresh FROM machines m JOIN machine_control_events e ON e.community_id=m.community_id AND e.event_id=m.registration_event_id LEFT JOIN machine_control_events o ON o.community_id=m.community_id AND o.event_id=m.observation_event_id WHERE m.community_id=$1 AND m.owner_pubkey=$2 AND ($3::uuid IS NULL OR m.machine_id>$3) ORDER BY m.machine_id LIMIT $4") + .bind(community.as_uuid()).bind(owner).bind(after).bind(limit).fetch_all(&self.pool).await?; + rows.iter().map(machine_json).collect() + } + + /// Uniform absent result for missing, other-owner and other-tenant machines. + #[datastore_span(name = "get_machine", system = "postgresql")] + pub async fn get_machine( + &self, + community: CommunityId, + owner: &[u8], + machine: Uuid, + ) -> Result> { + let row = sqlx::query("SELECT m.*, statement_timestamp() AS server_now, e.signed_event AS enrollment, o.signed_event AS observation, (m.expires_at > statement_timestamp() AND EXISTS(SELECT 1 FROM users u JOIN users owner ON owner.community_id=u.community_id AND owner.pubkey=m.owner_pubkey WHERE u.community_id=m.community_id AND u.pubkey=m.coordinator_pubkey AND u.agent_owner_pubkey=m.owner_pubkey AND u.machine_id=m.machine_id::text AND u.agent_type=m.runtime AND u.deactivated_at IS NULL AND owner.deactivated_at IS NULL)) AS fresh FROM machines m JOIN machine_control_events e ON e.community_id=m.community_id AND e.event_id=m.registration_event_id LEFT JOIN machine_control_events o ON o.community_id=m.community_id AND o.event_id=m.observation_event_id WHERE m.community_id=$1 AND m.owner_pubkey=$2 AND m.machine_id=$3") + .bind(community.as_uuid()).bind(owner).bind(machine).fetch_optional(&self.pool).await?; + row.as_ref().map(machine_json).transpose() + } +} + +fn machine_json(row: &sqlx::postgres::PgRow) -> Result { + Ok(json!({ + "machine_id": row.try_get::("machine_id")?, + "owner_pubkey": hex::encode(row.try_get::,_>("owner_pubkey")?), + "coordinator_pubkey": hex::encode(row.try_get::,_>("coordinator_pubkey")?), + "label": row.try_get::("label")?, + "runtime": row.try_get::("runtime")?, + "registration_event_id": hex::encode(row.try_get::,_>("registration_event_id")?), + "observation_sequence": row.try_get::("observation_sequence")?, + "reported_state": row.try_get::,_>("observed_state")?, + "server_now": row.try_get::,_>("server_now")?, + "fresh": row.try_get::,_>("fresh")?.unwrap_or(false), + "observed_at": row.try_get::>,_>("observed_at")?, + "received_at": row.try_get::>,_>("received_at")?, + "expires_at": row.try_get::>,_>("expires_at")?, + "enrollment_event": row.try_get::("enrollment")?, + "observation_event": row.try_get::,_>("observation")?, + })) +} + +async fn enroll( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + event: &Event, + enrollment: &MachineEnrollment, +) -> Result> { + let owner = event.pubkey.to_bytes().to_vec(); + let coordinator = hex::decode(&enrollment.coordinator_pubkey).map_err(|_| denied())?; + let existing: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM machines WHERE community_id=$1 AND (machine_id=$2 OR coordinator_pubkey=$3))") + .bind(community.as_uuid()).bind(enrollment.machine_id).bind(&coordinator).fetch_one(&mut **tx).await?; + if existing { + return Err(denied()); + } + // Deterministic user-row order also serializes different machine claims for + // one coordinator. No pre-transaction owner or profile materialization. + let mut identities = [owner.clone(), coordinator.clone()]; + identities.sort(); + for key in identities { + sqlx::query("INSERT INTO users(community_id,pubkey) VALUES($1,$2) ON CONFLICT DO NOTHING") + .bind(community.as_uuid()) + .bind(&key) + .execute(&mut **tx) + .await?; + let active: bool = sqlx::query_scalar("SELECT deactivated_at IS NULL FROM users WHERE community_id=$1 AND pubkey=$2 FOR UPDATE") + .bind(community.as_uuid()).bind(&key).fetch_one(&mut **tx).await?; + if !active { + return Err(denied()); + } + } + let row = sqlx::query("SELECT agent_owner_pubkey, agent_type, machine_id FROM users WHERE community_id=$1 AND pubkey=$2") + .bind(community.as_uuid()).bind(&coordinator).fetch_one(&mut **tx).await?; + if row + .try_get::>, _>("agent_owner_pubkey")? + .is_some_and(|v| v != owner) + || row.try_get::, _>("machine_id")?.is_some() + || row + .try_get::, _>("agent_type")? + .is_some_and(|v| v != enrollment.runtime.as_str()) + { + return Err(denied()); + } + let now: i64 = + sqlx::query_scalar("SELECT floor(extract(epoch FROM clock_timestamp()))::bigint") + .fetch_one(&mut **tx) + .await?; + validate_enrollment_consent_after_signature( + enrollment, + &event.pubkey, + event.created_at.as_secs(), + now, + ) + .map_err(|_| denied())?; + sqlx::query("UPDATE users SET agent_owner_pubkey=$3,agent_type=$4,machine_id=$5,machine_label=$6,machine_runtime=$4,updated_at=clock_timestamp() WHERE community_id=$1 AND pubkey=$2") + .bind(community.as_uuid()).bind(&coordinator).bind(&owner).bind(enrollment.runtime.as_str()).bind(enrollment.machine_id.to_string()).bind(&enrollment.label).execute(&mut **tx).await?; + sqlx::query("INSERT INTO machines(community_id,machine_id,owner_pubkey,coordinator_pubkey,registration_event_id,label,runtime) VALUES($1,$2,$3,$4,$5,$6,$7)") + .bind(community.as_uuid()).bind(enrollment.machine_id).bind(&owner).bind(coordinator).bind(event.id.as_bytes().as_slice()).bind(&enrollment.label).bind(enrollment.runtime.as_str()).execute(&mut **tx).await?; + Ok(owner) +} + +async fn observe( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + event: &Event, + observation: &MachineObservation, +) -> Result> { + let row = sqlx::query("SELECT m.* FROM machines m JOIN users u ON u.community_id=m.community_id AND u.pubkey=m.coordinator_pubkey JOIN users owner ON owner.community_id=m.community_id AND owner.pubkey=m.owner_pubkey WHERE m.community_id=$1 AND m.machine_id=$2 AND m.coordinator_pubkey=$3 AND u.agent_owner_pubkey=m.owner_pubkey AND u.machine_id=m.machine_id::text AND u.agent_type=m.runtime AND u.deactivated_at IS NULL AND owner.deactivated_at IS NULL FOR UPDATE OF m FOR SHARE OF u, owner") + .bind(community.as_uuid()).bind(observation.machine_id).bind(event.pubkey.to_bytes().as_slice()).fetch_optional(&mut **tx).await?.ok_or_else(denied)?; + if hex::encode(row.try_get::, _>("registration_event_id")?) + != observation.registration_event_id + || row.try_get::("observation_sequence")? >= observation.sequence + { + return Err(denied()); + } + let received: chrono::DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&mut **tx) + .await?; + let signed = i64::try_from(event.created_at.as_secs()).map_err(|_| denied())?; + if signed < received.timestamp() - MAX_OBSERVATION_AGE_SECS + || signed > received.timestamp() + MAX_CLOCK_SKEW_SECS + { + return Err(denied()); + } + let observed_at = chrono::DateTime::from_timestamp(signed, 0).ok_or_else(denied)?; + let expires = received.min(observed_at) + chrono::Duration::seconds(OBSERVATION_TTL_SECS); + let state = match observation.state { + MachineState::Ready => "ready", + MachineState::Busy => "busy", + MachineState::Unavailable => "unavailable", + }; + sqlx::query("UPDATE machines SET observation_event_id=$3,observation_sequence=$4,observed_state=$5,observed_at=$6,received_at=$7,expires_at=$8 WHERE community_id=$1 AND machine_id=$2") + .bind(community.as_uuid()).bind(observation.machine_id).bind(event.id.as_bytes().as_slice()).bind(observation.sequence).bind(state).bind(observed_at).bind(received).bind(expires).execute(&mut **tx).await?; + row.try_get("owner_pubkey").map_err(Into::into) +} + +#[cfg(test)] +mod postgres_tests; diff --git a/crates/buzz-db/src/store/machine/postgres_tests.rs b/crates/buzz-db/src/store/machine/postgres_tests.rs new file mode 100644 index 00000000000..344d7fd8a66 --- /dev/null +++ b/crates/buzz-db/src/store/machine/postgres_tests.rs @@ -0,0 +1,407 @@ +use super::*; +use nostr::{EventBuilder, Keys, Kind, Timestamp}; + +struct Fixture { + db: Db, + pool: sqlx::PgPool, + community: CommunityId, + owner: Keys, + coordinator: Keys, + machine: Uuid, +} +impl Fixture { + async fn new() -> Self { + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let community = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities(id,host) VALUES($1,$2)") + .bind(community.as_uuid()) + .bind(format!("machine-{}.invalid", community.as_uuid())) + .execute(&pool) + .await + .unwrap(); + Self { + db: Db::from_pool(pool.clone()), + pool, + community, + owner: Keys::generate(), + coordinator: Keys::generate(), + machine: Uuid::new_v4(), + } + } + fn enrollment(&self) -> Event { + // DB seam requires transport-verified proof; cryptographic owner proof is + // exercised by the real relay tests, not this transaction fixture. + let mut payload = json!({"version":1,"community_id":self.community.as_uuid(),"machine_id":self.machine,"coordinator_pubkey":self.coordinator.public_key().to_hex(),"label":"Mack","runtime":"hermes","owner_auth":["auth",self.owner.public_key().to_hex(),"kind=47210","a".repeat(128)]}); + let mut consent = payload.clone(); + consent["owner_pubkey"] = json!(self.owner.public_key().to_hex()); + consent["expires_at"] = json!(Timestamp::now().as_secs() + 300); + payload["coordinator_consent"] = serde_json::to_value( + EventBuilder::new(Kind::Custom(47212), consent.to_string()) + .sign_with_keys(&self.coordinator) + .unwrap(), + ) + .unwrap(); + EventBuilder::new(Kind::Custom(47210), payload.to_string()) + .sign_with_keys(&self.owner) + .unwrap() + } + fn observation(&self, enrollment: &Event, sequence: i64, timestamp: u64, keys: &Keys) -> Event { + EventBuilder::new(Kind::Custom(47211),json!({"version":1,"community_id":self.community.as_uuid(),"machine_id":self.machine,"registration_event_id":enrollment.id.to_hex(),"sequence":sequence,"state":"ready"}).to_string()).custom_created_at(Timestamp::from(timestamp)).sign_with_keys(keys).unwrap() + } + async fn read(&self) -> Value { + self.db + .get_machine( + self.community, + &self.owner.public_key().to_bytes(), + self.machine, + ) + .await + .unwrap() + .unwrap() + } + async fn count(&self, table: &str) -> i64 { + assert!([ + "machine_control_events", + "machines", + "events", + "agent_capability_grants" + ] + .contains(&table)); + sqlx::query_scalar(sqlx::AssertSqlSafe(format!( + "SELECT count(*) FROM {table} WHERE community_id=$1" + ))) + .bind(self.community.as_uuid()) + .fetch_one(&self.pool) + .await + .unwrap() + } +} + +// The response clock advances on every read; every persisted field, freshness +// decision and signed event must still remain equal after a rejected/replayed write. +fn without_response_clock(mut value: Value) -> Value { + assert!(value + .as_object_mut() + .unwrap() + .remove("server_now") + .is_some()); + value +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn private_machine_projection_supplies_bounded_statement_clock() { + let f = Fixture::new().await; + let enrollment = f.enrollment(); + f.db.apply_machine_command(f.community, &enrollment) + .await + .unwrap(); + let observation = f.observation(&enrollment, 1, Timestamp::now().as_secs(), &f.coordinator); + f.db.apply_machine_command(f.community, &observation) + .await + .unwrap(); + let before: chrono::DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&f.pool) + .await + .unwrap(); + let detail = f.read().await; + let list = + f.db.list_machines(f.community, &f.owner.public_key().to_bytes(), None, 1) + .await + .unwrap(); + let after: chrono::DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&f.pool) + .await + .unwrap(); + for row in [detail, list[0].clone()] { + let server_now = + chrono::DateTime::parse_from_rfc3339(row["server_now"].as_str().unwrap()).unwrap(); + let expires = + chrono::DateTime::parse_from_rfc3339(row["expires_at"].as_str().unwrap()).unwrap(); + assert!(server_now >= before && server_now <= after); + assert_eq!(row["fresh"], true); + let remaining = expires - server_now; + assert!( + remaining > chrono::Duration::zero() && remaining <= chrono::Duration::seconds(120) + ); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn private_machine_atomic_enrollment_duplicate_and_owner_pagination() { + let f = Fixture::new().await; + let event = f.enrollment(); + let (one, two) = tokio::join!( + f.db.apply_machine_command(f.community, &event), + f.db.apply_machine_command(f.community, &event) + ); + assert_eq!( + [one.unwrap(), two.unwrap()].iter().filter(|v| **v).count(), + 1 + ); + assert_eq!(f.count("machine_control_events").await, 1); + assert_eq!(f.count("events").await, 0); + assert_eq!(f.count("agent_capability_grants").await, 0); + let row = f.read().await; + assert_eq!(row["fresh"], false); + assert_eq!(row["enrollment_event"]["id"], event.id.to_hex()); + let user=sqlx::query("SELECT agent_owner_pubkey,agent_type,machine_id FROM users WHERE community_id=$1 AND pubkey=$2").bind(f.community.as_uuid()).bind(f.coordinator.public_key().to_bytes().as_slice()).fetch_one(&f.pool).await.unwrap(); + assert_eq!( + user.get::, _>("agent_owner_pubkey"), + f.owner.public_key().to_bytes() + ); + assert_eq!(user.get::("agent_type"), "hermes"); + assert_eq!(user.get::("machine_id"), f.machine.to_string()); + assert!(f + .db + .list_machines( + f.community, + &f.coordinator.public_key().to_bytes(), + None, + 50 + ) + .await + .unwrap() + .is_empty()); + assert!(f + .db + .get_machine( + f.community, + &f.coordinator.public_key().to_bytes(), + f.machine + ) + .await + .unwrap() + .is_none()); + assert!(f + .db + .get_machine( + CommunityId::from_uuid(Uuid::new_v4()), + &f.owner.public_key().to_bytes(), + f.machine + ) + .await + .unwrap() + .is_none()); + assert_eq!( + f.db.list_machines(f.community, &f.owner.public_key().to_bytes(), None, 1) + .await + .unwrap() + .len(), + 1 + ); + assert!(f + .db + .list_machines( + f.community, + &f.owner.public_key().to_bytes(), + Some(f.machine), + 1 + ) + .await + .unwrap() + .is_empty()); + // Interleave another owner's machine between two owned IDs; owner filtering + // must happen before LIMIT, including on subsequent cursor pages. + let mut page = Fixture { + db: f.db.clone(), + pool: f.pool.clone(), + community: f.community, + owner: f.owner.clone(), + coordinator: Keys::generate(), + machine: Uuid::from_u128(1), + }; + let first_id = page.machine; + page.db + .apply_machine_command(page.community, &page.enrollment()) + .await + .unwrap(); + page.owner = Keys::generate(); + page.coordinator = Keys::generate(); + page.machine = Uuid::from_u128(2); + page.db + .apply_machine_command(page.community, &page.enrollment()) + .await + .unwrap(); + page.owner = f.owner.clone(); + page.coordinator = Keys::generate(); + page.machine = Uuid::from_u128(3); + page.db + .apply_machine_command(page.community, &page.enrollment()) + .await + .unwrap(); + let first_page = + f.db.list_machines(f.community, &f.owner.public_key().to_bytes(), None, 1) + .await + .unwrap(); + assert_eq!(first_page[0]["machine_id"], first_id.to_string()); + let next_page = + f.db.list_machines( + f.community, + &f.owner.public_key().to_bytes(), + Some(first_id), + 1, + ) + .await + .unwrap(); + assert_eq!(next_page[0]["machine_id"], page.machine.to_string()); + f.db.validate_deletion_catalog().await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn private_machine_replacements_moves_and_foreign_ownership_roll_back() { + let mut f = Fixture::new().await; + let event = f.enrollment(); + f.db.apply_machine_command(f.community, &event) + .await + .unwrap(); + let original = f.read().await; + let original_coordinator = f.coordinator.clone(); + f.coordinator = Keys::generate(); + assert!(f + .db + .apply_machine_command(f.community, &f.enrollment()) + .await + .is_err()); + let absent: bool = sqlx::query_scalar( + "SELECT NOT EXISTS(SELECT 1 FROM users WHERE community_id=$1 AND pubkey=$2)", + ) + .bind(f.community.as_uuid()) + .bind(f.coordinator.public_key().to_bytes().as_slice()) + .fetch_one(&f.pool) + .await + .unwrap(); + assert!(absent); + assert_eq!( + without_response_clock(f.read().await), + without_response_clock(original) + ); + f.coordinator = original_coordinator; + f.machine = Uuid::new_v4(); + // The coordinator consents to the proposed new machine, so rejection must + // come from the existing durable registration rather than tampered consent. + let moved = f.enrollment(); + assert!(f + .db + .apply_machine_command(f.community, &moved) + .await + .is_err()); + assert_eq!(f.count("machine_control_events").await, 1); + let other = Fixture::new().await; + other + .db + .ensure_user(other.community, &f.owner.public_key().to_bytes()) + .await + .unwrap(); + sqlx::query("INSERT INTO users(community_id,pubkey,agent_owner_pubkey) VALUES($1,$2,$3)") + .bind(other.community.as_uuid()) + .bind(other.coordinator.public_key().to_bytes().as_slice()) + .bind(f.owner.public_key().to_bytes().as_slice()) + .execute(&f.pool) + .await + .unwrap(); + assert!(other + .db + .apply_machine_command(other.community, &other.enrollment()) + .await + .is_err()); + assert_eq!(other.count("machines").await, 0); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn private_machine_observation_binding_order_expiry_and_replay() { + let f = Fixture::new().await; + let enrollment = f.enrollment(); + f.db.apply_machine_command(f.community, &enrollment) + .await + .unwrap(); + let now = Timestamp::now().as_secs(); + let observation = f.observation(&enrollment, 1, now, &f.coordinator); + assert!(f + .db + .apply_machine_command(f.community, &observation) + .await + .unwrap()); + let first = f.read().await; + assert_eq!(first["fresh"], true); + assert!(!f + .db + .apply_machine_command(f.community, &observation) + .await + .unwrap()); + assert_eq!( + without_response_clock(f.read().await), + without_response_clock(first.clone()) + ); + for event in [ + f.observation(&enrollment, 2, now, &f.owner), + f.observation(&enrollment, 1, now + 1, &f.coordinator), + f.observation(&enrollment, 2, now - 31, &f.coordinator), + // Leave headroom for scheduling across a whole-second boundary. + f.observation( + &enrollment, + 2, + Timestamp::now().as_secs() + 60, + &f.coordinator, + ), + ] { + assert!(f + .db + .apply_machine_command(f.community, &event) + .await + .is_err()); + assert_eq!( + without_response_clock(f.read().await), + without_response_clock(first.clone()) + ); + } + let fake = EventBuilder::new(Kind::Custom(47210), "fake") + .sign_with_keys(&f.owner) + .unwrap(); + assert!(f + .db + .apply_machine_command(f.community, &f.observation(&fake, 2, now, &f.coordinator)) + .await + .is_err()); + let new = f.observation(&enrollment, 2, now, &f.coordinator); + assert!(f.db.apply_machine_command(f.community, &new).await.unwrap()); + sqlx::query("UPDATE machines SET observed_at=statement_timestamp()-interval '130 seconds',received_at=statement_timestamp()-interval '130 seconds',expires_at=statement_timestamp()-interval '10 seconds' WHERE community_id=$1").bind(f.community.as_uuid()).execute(&f.pool).await.unwrap(); + assert_eq!(f.read().await["fresh"], false); + assert!(!f.db.apply_machine_command(f.community, &new).await.unwrap()); + assert_eq!(f.read().await["fresh"], false); + sqlx::query("UPDATE users SET machine_id=NULL,machine_label=NULL,machine_runtime=NULL WHERE community_id=$1 AND pubkey=$2").bind(f.community.as_uuid()).bind(f.coordinator.public_key().to_bytes().as_slice()).execute(&f.pool).await.unwrap(); + assert!(f + .db + .apply_machine_command( + f.community, + &f.observation(&enrollment, 3, Timestamp::now().as_secs(), &f.coordinator) + ) + .await + .is_err()); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn private_machine_journal_failure_rolls_back_owner_type_and_home() { + let f = Fixture::new().await; + sqlx::raw_sql("CREATE FUNCTION reject_machine_journal() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'owned test rollback'; END $$; CREATE TRIGGER reject_machine_journal BEFORE INSERT ON machine_control_events FOR EACH ROW EXECUTE FUNCTION reject_machine_journal()") .execute(&f.pool).await.unwrap(); + assert!(f + .db + .apply_machine_command(f.community, &f.enrollment()) + .await + .is_err()); + assert_eq!(f.count("machines").await, 0); + assert_eq!(f.count("machine_control_events").await, 0); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM users WHERE community_id=$1") + .bind(f.community.as_uuid()) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!(count, 0); + sqlx::raw_sql("DROP TRIGGER reject_machine_journal ON machine_control_events; DROP FUNCTION reject_machine_journal()").execute(&f.pool).await.unwrap(); +} diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 1fa1273eb0f..de24e4f41fd 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -2,6 +2,8 @@ /// Explicit deployment-global admin report reads. pub mod admin_moderation; +/// Per-machine capability grants for agents (default deny). +pub mod agent_capability_grants; /// Community-scoped authentication allowlist persistence. pub mod allowlist; /// API token storage and lookup. @@ -22,6 +24,8 @@ pub mod dm; pub mod event; /// Home feed queries. pub mod feed; +/// Atomic fixed fleet attempt admission and signed receipt projection. +pub mod fleet_attempt; /// Git repository name registry (NIP-34 kind:30617). pub mod git_repo; /// Community moderation: reports, bans/timeouts, audit actions. @@ -54,3 +58,6 @@ pub mod usage; pub mod user; /// Workflow, run, and approval persistence. pub mod workflow; + +/// Private machine enrollment and observations. +pub mod machine; diff --git a/crates/buzz-db/src/store/push.rs b/crates/buzz-db/src/store/push.rs index 59c44fc5a83..707f7d8d169 100644 --- a/crates/buzz-db/src/store/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -2600,7 +2600,39 @@ mod postgres_tests { .await .expect("insert community-b event"); - let batch = claim_due_match_batch(&pool, 16, Utc::now() + chrono::Duration::minutes(1)) + // Anchor ordering to the database clock: a remote PostgreSQL server + // need not agree with the test runner's clock. Keep every due time in + // the past so the real SQL now() predicate needs no sleeps. + let database_now: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&pool) + .await + .expect("read database clock"); + let queued = sqlx::query( + "UPDATE push_match_queue SET next_attempt_at = CASE \ + WHEN community_id = $1 THEN $3::timestamptz - INTERVAL '3 minutes' \ + ELSE $3::timestamptz - INTERVAL '2 minutes' END \ + WHERE community_id IN ($1, $2)", + ) + .bind(community_a.as_uuid()) + .bind(community_b.as_uuid()) + .bind(database_now) + .execute(&pool) + .await + .expect("set deterministic due order"); + assert_eq!(queued.rows_affected(), 4); + let b_due: DateTime = sqlx::query_scalar( + "SELECT next_attempt_at FROM push_match_queue WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_b.as_uuid()) + .bind(b_event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("read persisted community-b due time"); + let retry_at = b_due + chrono::Duration::minutes(1); + assert!(retry_at < database_now, "retry must already be due"); + let lease_until = database_now + chrono::Duration::minutes(1); + + let batch = claim_due_match_batch(&pool, 16, lease_until) .await .expect("claim first batch") .expect("batch present"); @@ -2617,6 +2649,13 @@ mod postgres_tests { .iter() .map(|job| job.event.event.id.as_bytes().to_vec()) .collect(); + let mut actual_ids = claimed_ids.clone(); + actual_ids.sort(); + a_ids.sort(); + assert_eq!( + actual_ids, a_ids, + "claim every community-a event exactly once" + ); // A stale fence must not complete or retry anything. assert_eq!( complete_match_batch(&pool, batch.community, Uuid::new_v4(), &claimed_ids) @@ -2630,13 +2669,14 @@ mod postgres_tests { batch.community, Uuid::new_v4(), &claimed_ids, - Utc::now() + retry_at ) .await .expect("stale retry"), - 0 + 0, + "a stale retry claim must not change queued jobs" ); - // Complete two under the real fence, retry the third immediately. + // Complete two under the real fence, retry the third after B but still due. assert_eq!( complete_match_batch(&pool, batch.community, batch.claim_id, &claimed_ids[..2]) .await @@ -2649,7 +2689,7 @@ mod postgres_tests { batch.community, batch.claim_id, &claimed_ids[2..], - Utc::now() + retry_at ) .await .expect("retry one"), @@ -2658,12 +2698,16 @@ mod postgres_tests { // The retried job is claimable again, but its retry time is later // than community B's untouched row, so B's batch comes first. - let second = claim_due_match_batch(&pool, 16, Utc::now() + chrono::Duration::minutes(1)) + let second = claim_due_match_batch(&pool, 16, lease_until) .await .expect("claim community-b batch") .expect("community-b batch present"); - assert_eq!(second.community, community_b); + assert_eq!( + second.community, community_b, + "untouched community-b job must precede the retried community-a job" + ); assert_eq!(second.jobs.len(), 1); + assert_eq!(second.jobs[0].event.event.id, b_event.id); assert_eq!( complete_match_batch( &pool, @@ -2677,13 +2721,17 @@ mod postgres_tests { ); // Then the retried community-a job, on its second attempt. - let third = claim_due_match_batch(&pool, 16, Utc::now() + chrono::Duration::minutes(1)) + let third = claim_due_match_batch(&pool, 16, lease_until) .await .expect("claim retried job") .expect("retried job present"); assert_eq!(third.community, community_a); assert_eq!(third.jobs.len(), 1); assert_eq!(third.jobs[0].attempt, 2); + assert_eq!( + third.jobs[0].event.event.id.as_bytes().as_slice(), + claimed_ids[2].as_slice() + ); assert_eq!( complete_match_batch( &pool, diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index ecde3924fef..5e2faf7a851 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -664,6 +664,17 @@ pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> R } impl Db { + /// Check relay membership on the writer for immediate authorization + /// decisions that must observe a just-committed revocation. + #[datastore_span(name = "is_relay_member_writer", system = "postgresql")] + pub async fn is_relay_member_writer( + &self, + community: CommunityId, + pubkey: &str, + ) -> Result { + is_relay_member(&self.pool, community, pubkey).await + } + /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. /// /// Replica-routed on the bounded arm — the one PERMISSION read routed by diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index 759e916e4b4..5afc3b756c1 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -360,6 +360,129 @@ async fn set_agent_owner_with_operation( Ok(true) } +/// The machine an agent calls home: stable host id, human label, and runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MachineHome { + /// Stable host identity (the desktop's device id). + pub machine_id: String, + /// Human-facing, renameable label. + pub machine_label: Option, + /// Runtime serving this home (`"hermes"`, `"openclaw"`, `"claude-code"`, ...). + pub machine_runtime: Option, +} + +/// Register (or re-register) `agent_pubkey` as the home agent for a machine. +/// +/// One home per machine per community is a database invariant +/// (`idx_users_one_home_per_machine`), not a check performed here: a +/// read-then-write would race two concurrent registrations onto the same host. +/// A conflicting claim therefore surfaces as a unique violation, which is +/// translated to [`DbError::AccessDenied`] so callers get an actionable +/// message instead of a raw SQLSTATE. +/// +/// Returns `Err(DbError::NotFound)` if the agent pubkey has no `users` row. +pub async fn set_machine_home( + pool: &PgPool, + community_id: CommunityId, + agent_pubkey: &[u8], + home: &MachineHome, +) -> Result<()> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let result = sqlx::query( + r#"UPDATE users SET machine_id = $1, machine_label = $2, machine_runtime = $3, updated_at = NOW() WHERE community_id = $4 AND pubkey = $5"#, + ) + .bind(&home.machine_id) + .bind(home.machine_label.as_deref()) + .bind(home.machine_runtime.as_deref()) + .bind(community_id.as_uuid()) + .bind(agent_pubkey) + .execute(&mut *connection) + .await; + + match result { + Ok(done) if done.rows_affected() == 0 => Err(crate::error::DbError::NotFound( + "agent pubkey not found in users table".into(), + )), + Ok(_) => Ok(()), + // 23505 = unique_violation: another agent already homes this machine. + Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("23505") => { + Err(crate::error::DbError::AccessDenied(format!( + "machine {} already has a home agent in this community", + home.machine_id + ))) + } + Err(e) => Err(e.into()), + } +} + +/// Clear an agent's machine home, freeing the machine for another agent. +/// +/// Returns `true` if a home was cleared, `false` if the row exists but had no +/// home. All three columns drop together: the migration's +/// `chk_users_machine_fields_require_machine_id` makes a label or runtime +/// without a `machine_id` unrepresentable. +pub async fn clear_machine_home( + pool: &PgPool, + community_id: CommunityId, + agent_pubkey: &[u8], +) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let result = sqlx::query( + r#"UPDATE users SET machine_id = NULL, machine_label = NULL, machine_runtime = NULL, updated_at = NOW() WHERE community_id = $1 AND pubkey = $2 AND machine_id IS NOT NULL"#, + ) + .bind(community_id.as_uuid()) + .bind(agent_pubkey) + .execute(&mut *connection) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Look up an agent's machine home. `None` when the user is absent or unhomed. +pub async fn get_machine_home( + pool: &PgPool, + community_id: CommunityId, + agent_pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + r#"SELECT machine_id, machine_label, machine_runtime FROM users WHERE community_id = $1 AND pubkey = $2 AND machine_id IS NOT NULL"#, + ) + .bind(community_id.as_uuid()) + .bind(agent_pubkey) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| MachineHome { + machine_id: r.get("machine_id"), + machine_label: r.get("machine_label"), + machine_runtime: r.get("machine_runtime"), + })) +} + +/// Resolve the agent that homes `machine_id`, if any. +/// +/// This is the lookup that makes a machine home addressable: given a host, find +/// the pubkey that answers for it. +pub async fn get_agent_for_machine( + pool: &PgPool, + community_id: CommunityId, + machine_id: &str, +) -> Result>> { + let row = + sqlx::query(r#"SELECT pubkey FROM users WHERE community_id = $1 AND machine_id = $2"#) + .bind(community_id.as_uuid()) + .bind(machine_id) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.get::, _>("pubkey"))) +} + /// Get the channel_add_policy and agent_owner_pubkey for a user. /// Returns None if the pubkey is not in the users table. /// Returns Some((policy_str, owner_bytes_or_none)) if found. @@ -592,273 +715,4 @@ impl Db { } #[cfg(test)] -mod postgres_tests { - use super::*; - use crate::Db; - use nostr::Keys; - - async fn setup_db() -> Db { - let pool = PgPool::connect(&crate::test_support::database_url()) - .await - .expect("connect to test DB"); - Db::from_pool(pool) - } - - fn random_pubkey() -> Vec { - Keys::generate().public_key().to_bytes().to_vec() - } - - async fn make_community(pool: &PgPool) -> CommunityId { - let id = uuid::Uuid::new_v4(); - let host = format!("user-test-{}.example", id.simple()); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(id) - .bind(host) - .execute(pool) - .await - .expect("insert test community"); - CommunityId::from_uuid(id) - } - - /// Setting an agent owner then reading back the policy should return - /// the default "anyone" policy and the owner pubkey. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn test_set_agent_owner_and_get_policy() { - let db = setup_db().await; - let community = make_community(&db.pool).await; - let agent_pk = random_pubkey(); - let owner_pk = random_pubkey(); - - ensure_user(&db.pool, community, &agent_pk) - .await - .expect("ensure agent"); - ensure_user(&db.pool, community, &owner_pk) - .await - .expect("ensure owner"); - - let was_set = set_agent_owner(&db.pool, community, &agent_pk, &owner_pk) - .await - .expect("set_agent_owner"); - assert!(was_set, "first set_agent_owner should return true"); - - let result = get_agent_channel_policy(&db.pool, community, &agent_pk) - .await - .expect("get_agent_channel_policy"); - - let (policy, owner) = result.expect("should return Some for known pubkey"); - assert_eq!(policy, "anyone", "default policy should be 'anyone'"); - assert_eq!( - owner, - Some(owner_pk), - "owner pubkey should match what was set" - ); - } - - /// set_channel_add_policy should persist each of the three valid policies. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn test_set_channel_add_policy() { - let db = setup_db().await; - let community = make_community(&db.pool).await; - let pk = random_pubkey(); - ensure_user(&db.pool, community, &pk) - .await - .expect("ensure user"); - - // owner_only - set_channel_add_policy(&db.pool, community, &pk, "owner_only") - .await - .expect("set owner_only"); - let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk) - .await - .expect("get policy") - .expect("should be Some"); - assert_eq!(policy, "owner_only"); - assert!(owner.is_none(), "no owner was set"); - - // nobody - set_channel_add_policy(&db.pool, community, &pk, "nobody") - .await - .expect("set nobody"); - let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk) - .await - .expect("get policy") - .expect("should be Some"); - assert_eq!(policy, "nobody"); - assert!(owner.is_none()); - - // anyone (reset to default) - set_channel_add_policy(&db.pool, community, &pk, "anyone") - .await - .expect("set anyone"); - let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk) - .await - .expect("get policy") - .expect("should be Some"); - assert_eq!(policy, "anyone"); - assert!(owner.is_none()); - } - - /// get_agent_channel_policy should return None for a pubkey that has - /// never been inserted into the users table. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn test_get_policy_unknown_pubkey() { - let db = setup_db().await; - let community = make_community(&db.pool).await; - let pk = random_pubkey(); - - let result = get_agent_channel_policy(&db.pool, community, &pk) - .await - .expect("query should not error"); - - assert!(result.is_none(), "unknown pubkey should return None"); - } - - /// set_agent_owner should return Err when the agent pubkey does not exist - /// in the users table. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn test_set_agent_owner_nonexistent_agent() { - let db = setup_db().await; - let community = make_community(&db.pool).await; - let agent_pk = random_pubkey(); - let owner_pk = random_pubkey(); - - // Only ensure the owner exists -- agent is intentionally absent. - ensure_user(&db.pool, community, &owner_pk) - .await - .expect("ensure owner"); - - let result = set_agent_owner(&db.pool, community, &agent_pk, &owner_pk).await; - assert!( - result.is_err(), - "should error when agent pubkey is not in users table" - ); - } - - /// set_agent_owner should return Ok(false) when the agent already has an owner. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn test_set_agent_owner_already_owned() { - let db = setup_db().await; - let community = make_community(&db.pool).await; - let agent_pk = random_pubkey(); - let owner1 = random_pubkey(); - let owner2 = random_pubkey(); - - ensure_user(&db.pool, community, &agent_pk) - .await - .expect("ensure agent"); - ensure_user(&db.pool, community, &owner1) - .await - .expect("ensure owner1"); - ensure_user(&db.pool, community, &owner2) - .await - .expect("ensure owner2"); - - let first = set_agent_owner(&db.pool, community, &agent_pk, &owner1) - .await - .expect("first set"); - assert!(first, "first set should succeed"); - - let second = set_agent_owner(&db.pool, community, &agent_pk, &owner2) - .await - .expect("second set should not error"); - assert!(!second, "second set should return false (already owned)"); - - // Verify original owner is preserved. - let (_, owner) = get_agent_channel_policy(&db.pool, community, &agent_pk) - .await - .expect("get policy") - .expect("should be Some"); - assert_eq!(owner, Some(owner1), "original owner should be preserved"); - } - - /// set_channel_add_policy should return Err when the pubkey does not exist - /// in the users table (0 rows affected -> NotFound). - #[tokio::test] - #[ignore = "requires Postgres"] - async fn test_set_channel_add_policy_nonexistent_user() { - let db = setup_db().await; - let community = make_community(&db.pool).await; - let pk = random_pubkey(); - - let result = set_channel_add_policy(&db.pool, community, &pk, "nobody").await; - assert!( - result.is_err(), - "should error when pubkey is not in users table" - ); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn test_set_channel_add_policy_rejects_invalid() { - let db = setup_db().await; - let community = make_community(&db.pool).await; - let pubkey = nostr::Keys::generate().public_key().to_bytes().to_vec(); - ensure_user(&db.pool, community, &pubkey).await.unwrap(); - let result = set_channel_add_policy(&db.pool, community, &pubkey, "invalid_policy").await; - assert!(result.is_err(), "should reject invalid policy value"); - } - - // Use the production `escape_like` function directly — no local mirror. - use super::escape_like; - - #[test] - fn like_escape_percent() { - assert_eq!(escape_like("%"), "\\%"); - assert_eq!(escape_like("100%match"), "100\\%match"); - } - - #[test] - fn like_escape_underscore() { - assert_eq!(escape_like("_"), "\\_"); - assert_eq!(escape_like("a_b"), "a\\_b"); - } - - #[test] - fn like_escape_backslash() { - assert_eq!(escape_like("\\"), "\\\\"); - assert_eq!(escape_like("a\\b"), "a\\\\b"); - } - - #[test] - fn like_escape_combined() { - // All three metacharacters in one string - assert_eq!(escape_like("%_\\"), "\\%\\_\\\\"); - } - - #[test] - fn like_escape_normal_input_unchanged() { - assert_eq!(escape_like("alice"), "alice"); - assert_eq!(escape_like("bob@example.com"), "bob@example.com"); - assert_eq!(escape_like(""), ""); - } - - /// A user with "owner_only" policy but no agent_owner_pubkey set should - /// return Some(("owner_only", None)). - #[tokio::test] - #[ignore = "requires Postgres"] - async fn test_owner_only_with_no_owner() { - let db = setup_db().await; - let community = make_community(&db.pool).await; - let pk = random_pubkey(); - ensure_user(&db.pool, community, &pk) - .await - .expect("ensure user"); - - set_channel_add_policy(&db.pool, community, &pk, "owner_only") - .await - .expect("set owner_only"); - - let result = get_agent_channel_policy(&db.pool, community, &pk) - .await - .expect("get policy") - .expect("should be Some"); - - assert_eq!(result.0, "owner_only"); - assert!(result.1.is_none(), "owner should be None when never set"); - } -} +mod postgres_tests; diff --git a/crates/buzz-db/src/store/user/postgres_tests.rs b/crates/buzz-db/src/store/user/postgres_tests.rs new file mode 100644 index 00000000000..aa7ccb56d2f --- /dev/null +++ b/crates/buzz-db/src/store/user/postgres_tests.rs @@ -0,0 +1,459 @@ +use super::*; +use crate::Db; +use nostr::Keys; + +async fn setup_db() -> Db { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB"); + Db::from_pool(pool) +} + +fn random_pubkey() -> Vec { + Keys::generate().public_key().to_bytes().to_vec() +} + +async fn make_community(pool: &PgPool) -> CommunityId { + let id = uuid::Uuid::new_v4(); + let host = format!("user-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) +} + +/// Setting an agent owner then reading back the policy should return +/// the default "anyone" policy and the owner pubkey. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn test_set_agent_owner_and_get_policy() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let agent_pk = random_pubkey(); + let owner_pk = random_pubkey(); + + ensure_user(&db.pool, community, &agent_pk) + .await + .expect("ensure agent"); + ensure_user(&db.pool, community, &owner_pk) + .await + .expect("ensure owner"); + + let was_set = set_agent_owner(&db.pool, community, &agent_pk, &owner_pk) + .await + .expect("set_agent_owner"); + assert!(was_set, "first set_agent_owner should return true"); + + let result = get_agent_channel_policy(&db.pool, community, &agent_pk) + .await + .expect("get_agent_channel_policy"); + + let (policy, owner) = result.expect("should return Some for known pubkey"); + assert_eq!(policy, "anyone", "default policy should be 'anyone'"); + assert_eq!( + owner, + Some(owner_pk), + "owner pubkey should match what was set" + ); +} + +/// set_channel_add_policy should persist each of the three valid policies. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn test_set_channel_add_policy() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let pk = random_pubkey(); + ensure_user(&db.pool, community, &pk) + .await + .expect("ensure user"); + + // owner_only + set_channel_add_policy(&db.pool, community, &pk, "owner_only") + .await + .expect("set owner_only"); + let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk) + .await + .expect("get policy") + .expect("should be Some"); + assert_eq!(policy, "owner_only"); + assert!(owner.is_none(), "no owner was set"); + + // nobody + set_channel_add_policy(&db.pool, community, &pk, "nobody") + .await + .expect("set nobody"); + let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk) + .await + .expect("get policy") + .expect("should be Some"); + assert_eq!(policy, "nobody"); + assert!(owner.is_none()); + + // anyone (reset to default) + set_channel_add_policy(&db.pool, community, &pk, "anyone") + .await + .expect("set anyone"); + let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk) + .await + .expect("get policy") + .expect("should be Some"); + assert_eq!(policy, "anyone"); + assert!(owner.is_none()); +} + +/// get_agent_channel_policy should return None for a pubkey that has +/// never been inserted into the users table. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn test_get_policy_unknown_pubkey() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let pk = random_pubkey(); + + let result = get_agent_channel_policy(&db.pool, community, &pk) + .await + .expect("query should not error"); + + assert!(result.is_none(), "unknown pubkey should return None"); +} + +/// set_agent_owner should return Err when the agent pubkey does not exist +/// in the users table. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn test_set_agent_owner_nonexistent_agent() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let agent_pk = random_pubkey(); + let owner_pk = random_pubkey(); + + // Only ensure the owner exists -- agent is intentionally absent. + ensure_user(&db.pool, community, &owner_pk) + .await + .expect("ensure owner"); + + let result = set_agent_owner(&db.pool, community, &agent_pk, &owner_pk).await; + assert!( + result.is_err(), + "should error when agent pubkey is not in users table" + ); +} + +/// set_agent_owner should return Ok(false) when the agent already has an owner. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn test_set_agent_owner_already_owned() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let agent_pk = random_pubkey(); + let owner1 = random_pubkey(); + let owner2 = random_pubkey(); + + ensure_user(&db.pool, community, &agent_pk) + .await + .expect("ensure agent"); + ensure_user(&db.pool, community, &owner1) + .await + .expect("ensure owner1"); + ensure_user(&db.pool, community, &owner2) + .await + .expect("ensure owner2"); + + let first = set_agent_owner(&db.pool, community, &agent_pk, &owner1) + .await + .expect("first set"); + assert!(first, "first set should succeed"); + + let second = set_agent_owner(&db.pool, community, &agent_pk, &owner2) + .await + .expect("second set should not error"); + assert!(!second, "second set should return false (already owned)"); + + // Verify original owner is preserved. + let (_, owner) = get_agent_channel_policy(&db.pool, community, &agent_pk) + .await + .expect("get policy") + .expect("should be Some"); + assert_eq!(owner, Some(owner1), "original owner should be preserved"); +} + +/// set_channel_add_policy should return Err when the pubkey does not exist +/// in the users table (0 rows affected -> NotFound). +#[tokio::test] +#[ignore = "requires Postgres"] +async fn test_set_channel_add_policy_nonexistent_user() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let pk = random_pubkey(); + + let result = set_channel_add_policy(&db.pool, community, &pk, "nobody").await; + assert!( + result.is_err(), + "should error when pubkey is not in users table" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn test_set_channel_add_policy_rejects_invalid() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let pubkey = nostr::Keys::generate().public_key().to_bytes().to_vec(); + ensure_user(&db.pool, community, &pubkey).await.unwrap(); + let result = set_channel_add_policy(&db.pool, community, &pubkey, "invalid_policy").await; + assert!(result.is_err(), "should reject invalid policy value"); +} + +// Use the production `escape_like` function directly — no local mirror. +use super::escape_like; + +#[test] +fn like_escape_percent() { + assert_eq!(escape_like("%"), "\\%"); + assert_eq!(escape_like("100%match"), "100\\%match"); +} + +#[test] +fn like_escape_underscore() { + assert_eq!(escape_like("_"), "\\_"); + assert_eq!(escape_like("a_b"), "a\\_b"); +} + +#[test] +fn like_escape_backslash() { + assert_eq!(escape_like("\\"), "\\\\"); + assert_eq!(escape_like("a\\b"), "a\\\\b"); +} + +#[test] +fn like_escape_combined() { + // All three metacharacters in one string + assert_eq!(escape_like("%_\\"), "\\%\\_\\\\"); +} + +#[test] +fn like_escape_normal_input_unchanged() { + assert_eq!(escape_like("alice"), "alice"); + assert_eq!(escape_like("bob@example.com"), "bob@example.com"); + assert_eq!(escape_like(""), ""); +} + +/// A user with "owner_only" policy but no agent_owner_pubkey set should +/// return Some(("owner_only", None)). +#[tokio::test] +#[ignore = "requires Postgres"] +async fn test_owner_only_with_no_owner() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let pk = random_pubkey(); + ensure_user(&db.pool, community, &pk) + .await + .expect("ensure user"); + + set_channel_add_policy(&db.pool, community, &pk, "owner_only") + .await + .expect("set owner_only"); + + let result = get_agent_channel_policy(&db.pool, community, &pk) + .await + .expect("get policy") + .expect("should be Some"); + + assert_eq!(result.0, "owner_only"); + assert!(result.1.is_none(), "owner should be None when never set"); +} + +/// A registered machine home round-trips, and the machine resolves back to +/// the agent that homes it. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn test_machine_home_round_trip() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let agent = random_pubkey(); + ensure_user(&db.pool, community, &agent) + .await + .expect("ensure agent"); + + let home = MachineHome { + machine_id: format!("machine-{}", uuid::Uuid::new_v4()), + machine_label: Some("Winnie".to_owned()), + machine_runtime: Some("openclaw".to_owned()), + }; + set_machine_home(&db.pool, community, &agent, &home) + .await + .expect("set home"); + + let got = get_machine_home(&db.pool, community, &agent) + .await + .expect("get home") + .expect("home should be Some"); + assert_eq!(got, home); + + let resolved = get_agent_for_machine(&db.pool, community, &home.machine_id) + .await + .expect("resolve machine") + .expect("machine should resolve"); + assert_eq!(resolved, agent, "machine must resolve to its home agent"); +} + +/// The core PR-3 invariant: one home agent per machine, per community. The +/// second claim must be rejected rather than silently stealing the host. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn test_second_agent_cannot_claim_same_machine() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let first = random_pubkey(); + let second = random_pubkey(); + ensure_user(&db.pool, community, &first).await.expect("a"); + ensure_user(&db.pool, community, &second).await.expect("b"); + + let machine_id = format!("machine-{}", uuid::Uuid::new_v4()); + let home = MachineHome { + machine_id: machine_id.clone(), + machine_label: None, + machine_runtime: None, + }; + set_machine_home(&db.pool, community, &first, &home) + .await + .expect("first claim wins"); + + let conflict = set_machine_home(&db.pool, community, &second, &home).await; + assert!( + matches!(conflict, Err(crate::error::DbError::AccessDenied(_))), + "second claim on the same machine must be denied, got {conflict:?}" + ); + + // The original home is untouched by the failed claim. + let still = get_agent_for_machine(&db.pool, community, &machine_id) + .await + .expect("resolve") + .expect("still homed"); + assert_eq!(still, first); +} + +/// Admission confinement: the same machine id in a different community is a +/// different machine, so the unique index must not collide across tenants. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn test_same_machine_id_allowed_in_other_community() { + let db = setup_db().await; + let community_a = make_community(&db.pool).await; + let community_b = make_community(&db.pool).await; + let agent_a = random_pubkey(); + let agent_b = random_pubkey(); + ensure_user(&db.pool, community_a, &agent_a) + .await + .expect("a"); + ensure_user(&db.pool, community_b, &agent_b) + .await + .expect("b"); + + let home = MachineHome { + machine_id: format!("machine-{}", uuid::Uuid::new_v4()), + machine_label: None, + machine_runtime: None, + }; + set_machine_home(&db.pool, community_a, &agent_a, &home) + .await + .expect("community A claim"); + set_machine_home(&db.pool, community_b, &agent_b, &home) + .await + .expect("community B must be independent of A"); +} + +/// Clearing a home frees the machine for another agent to claim. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn test_clear_machine_home_frees_the_machine() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let first = random_pubkey(); + let second = random_pubkey(); + ensure_user(&db.pool, community, &first).await.expect("a"); + ensure_user(&db.pool, community, &second).await.expect("b"); + + let home = MachineHome { + machine_id: format!("machine-{}", uuid::Uuid::new_v4()), + machine_label: None, + machine_runtime: None, + }; + set_machine_home(&db.pool, community, &first, &home) + .await + .expect("first claim"); + + assert!( + clear_machine_home(&db.pool, community, &first) + .await + .expect("clear"), + "clearing an existing home reports true" + ); + assert!( + get_machine_home(&db.pool, community, &first) + .await + .expect("get") + .is_none(), + "home is gone after clear" + ); + assert!( + !clear_machine_home(&db.pool, community, &first) + .await + .expect("clear again"), + "clearing an unhomed agent reports false" + ); + + set_machine_home(&db.pool, community, &second, &home) + .await + .expect("machine is free for the next agent"); +} + +/// Registering a home for a pubkey with no users row is an error, not a +/// silent no-op that would leave the machine unhomed. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn test_set_machine_home_nonexistent_agent() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let ghost = random_pubkey(); + + let home = MachineHome { + machine_id: format!("machine-{}", uuid::Uuid::new_v4()), + machine_label: None, + machine_runtime: None, + }; + let result = set_machine_home(&db.pool, community, &ghost, &home).await; + assert!( + matches!(result, Err(crate::error::DbError::NotFound(_))), + "unknown agent must not be homed, got {result:?}" + ); +} + +/// A label or runtime with no machine_id is unaddressable, and the database +/// must reject it rather than storing a half-registered home. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn test_label_without_machine_id_is_rejected() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let agent = random_pubkey(); + ensure_user(&db.pool, community, &agent) + .await + .expect("ensure agent"); + + let result = + sqlx::query("UPDATE users SET machine_label = $1 WHERE community_id = $2 AND pubkey = $3") + .bind("orphan-label") + .bind(community.as_uuid()) + .bind(&agent) + .execute(&db.pool) + .await; + assert!( + result.is_err(), + "a label with no machine_id must violate chk_users_machine_fields_require_machine_id" + ); +} diff --git a/crates/buzz-db/src/store/workflow.rs b/crates/buzz-db/src/store/workflow.rs index 3ceed9ea32e..b7cabe11246 100644 --- a/crates/buzz-db/src/store/workflow.rs +++ b/crates/buzz-db/src/store/workflow.rs @@ -11,6 +11,7 @@ use std::fmt; use std::str::FromStr; use chrono::{DateTime, Utc}; + use sha2::{Digest, Sha256}; use sqlx::{PgPool, Row}; use uuid::Uuid; @@ -32,7 +33,7 @@ pub const LIST_MAX_LIMIT: i64 = 1000; /// /// Approval tokens are stored hashed so that a DB read does not expose /// the raw token (same pattern as API tokens in buzz-auth). -fn hash_approval_token(token: &str) -> Vec { +pub fn hash_approval_token(token: &str) -> Vec { Sha256::digest(token.as_bytes()).to_vec() } @@ -259,6 +260,14 @@ pub struct ApprovalRecord { pub step_index: i32, /// Who may approve (user mention or role spec). pub approver_spec: String, + /// Human-readable request rendered when the workflow suspended. + pub request_message: Option, + /// Exact saved workflow revision covered by this approval. + pub definition_hash: Option, + /// Signed native approval request identity. + pub request_event_id: Option>, + /// Signed decision identity, populated only after an accepted decision. + pub decision_event_id: Option>, /// Current status of this approval request. pub status: ApprovalStatus, /// Compressed public key bytes of the user who acted on this approval. @@ -271,6 +280,9 @@ pub struct ApprovalRecord { pub created_at: DateTime, } +/// Durable approval suspension, decision, and one-use continuation claims. +pub mod approval; + // -- Workflow CRUD ------------------------------------------------------------ /// Insert a new workflow record. Returns the new workflow's UUID. @@ -912,6 +924,10 @@ pub struct WorkflowRunFailure<'a> { /// Update run status, current step, execution trace, and optional failure. /// +/// Terminal outcomes cannot be overwritten; a saved approval wait cannot be +/// completed or failed by a late executor finalizer. Progress never moves behind +/// an admitted continuation. Approval decisions and claims use atomic transactions. +/// /// Fix C3: `started_at` is set when the NEW status is 'running' and `started_at` /// has not yet been stamped (IS NULL). The original code read `status` from the /// column AFTER `SET status = ?` had already changed it, so the condition was @@ -942,6 +958,9 @@ pub async fn update_workflow_run( completed_at = CASE WHEN $7 IN ('completed','failed','cancelled') THEN NOW() ELSE completed_at END WHERE community_id = $8 AND id = $9 + AND status NOT IN ('completed','failed','cancelled') + AND NOT (status = 'waiting_approval' AND $6 IN ('completed','failed')) + AND current_step <= $2 "#, ) .bind(&status_str) @@ -1052,7 +1071,7 @@ pub async fn get_approval_by_stored_hash( let row = sqlx::query( r#" SELECT token, workflow_id, run_id, step_id, step_index, approver_spec, - status::text AS status, approver_pubkey, note, expires_at, created_at + status::text AS status, approver_pubkey, note, expires_at, created_at, request_message, request_event_id, decision_event_id, continuation->>'definition_hash' AS approval_definition_hash FROM workflow_approvals WHERE community_id = $1 AND token = $2 "#, @@ -1076,7 +1095,7 @@ pub async fn get_run_approvals( let rows = sqlx::query( r#" SELECT token, workflow_id, run_id, step_id, step_index, approver_spec, - status::text AS status, approver_pubkey, note, expires_at, created_at + status::text AS status, approver_pubkey, note, expires_at, created_at, request_message, request_event_id, decision_event_id, continuation->>'definition_hash' AS approval_definition_hash FROM workflow_approvals WHERE community_id = $1 AND run_id = $2 AND workflow_id = $3 ORDER BY step_index, created_at @@ -1231,6 +1250,10 @@ fn row_to_approval_record(row: sqlx::postgres::PgRow) -> Result step_id: row.try_get("step_id")?, step_index: row.try_get("step_index")?, approver_spec: row.try_get("approver_spec")?, + request_message: row.try_get("request_message")?, + definition_hash: row.try_get("approval_definition_hash")?, + request_event_id: row.try_get("request_event_id")?, + decision_event_id: row.try_get("decision_event_id")?, status, approver_pubkey: row.try_get("approver_pubkey")?, note: row.try_get("note")?, @@ -2066,6 +2089,10 @@ mod postgres_tests { run_id, step_id: "request_approval".to_owned(), step_index: 1, + request_message: None, + definition_hash: None, + request_event_id: None, + decision_event_id: None, approver_spec: "@engineering-lead".to_owned(), status: ApprovalStatus::Pending, approver_pubkey: None, @@ -2096,6 +2123,10 @@ mod postgres_tests { run_id: Uuid::new_v4(), step_id: "gate".to_owned(), step_index: 0, + request_message: None, + definition_hash: None, + request_event_id: None, + decision_event_id: None, approver_spec: "@manager".to_owned(), status: ApprovalStatus::Granted, approver_pubkey: Some(approver_pubkey.clone()), @@ -2119,6 +2150,10 @@ mod postgres_tests { run_id: Uuid::new_v4(), step_id: "gate".to_owned(), step_index: 0, + request_message: None, + definition_hash: None, + request_event_id: None, + decision_event_id: None, approver_spec: "@manager".to_owned(), status: ApprovalStatus::Denied, approver_pubkey: Some(vec![0xbb; 32]), @@ -2140,6 +2175,10 @@ mod postgres_tests { run_id: Uuid::new_v4(), step_id: "gate".to_owned(), step_index: 0, + request_message: None, + definition_hash: None, + request_event_id: None, + decision_event_id: None, approver_spec: "@lead".to_owned(), status: ApprovalStatus::Pending, approver_pubkey: None, diff --git a/crates/buzz-db/src/store/workflow/approval.rs b/crates/buzz-db/src/store/workflow/approval.rs new file mode 100644 index 00000000000..c86e3748efe --- /dev/null +++ b/crates/buzz-db/src/store/workflow/approval.rs @@ -0,0 +1,986 @@ +//! Atomic approval waits and one-use continuation admission. + +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; +use serde_json::Value; +use sqlx::{Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::{Db, DbError, Result}; + +/// Complete immutable state saved by the executor before returning suspended. +#[derive(Debug, Clone)] +pub struct ApprovalWait { + /// Owning tenant, derived from the run. + pub community_id: CommunityId, + /// Owning workflow. + pub workflow_id: Uuid, + /// Suspended run. + pub run_id: Uuid, + /// Channel for the signed request and decision audit events. + pub channel_id: Uuid, + /// Random public approval reference (not a bearer credential). + pub reference: Vec, + /// Authored step ID. + pub step_id: String, + /// Suspended step index. + pub step_index: i32, + /// Explicit approver pubkey or `any` current channel member. + pub approver_spec: String, + /// Rendered customer-facing request. + pub message: String, + /// Bounded lifetime, in seconds. + pub timeout_secs: i64, + /// Immutable definition, owner, trigger context, outputs and next step. + pub continuation: Value, + /// Full trace including the pending approval. + pub trace: Value, +} + +/// Durable outcome returned for a signed decision, including exact replay. +#[derive(Debug, Clone)] +pub struct DecisionReceipt { + /// Run affected by this decision. + pub run_id: Uuid, + /// Terminal approval status. + pub status: String, + /// Whether this exact signed decision had already committed. + pub duplicate: bool, +} + +/// An admitted continuation. The database never returns a claim twice. +#[derive(Debug)] +pub struct ApprovalContinuation { + /// Owning tenant. + pub community_id: CommunityId, + /// One-use approval reference. + pub reference: Vec, + /// Run to continue. + pub run_id: Uuid, + /// Owning workflow. + pub workflow_id: Uuid, + /// Saved, immutable execution state. + pub snapshot: Value, + /// Trace prefix, including the signed decision. + pub trace: Value, + /// First step after the approval. + pub next_step: i32, + /// Signer whose current channel membership is rechecked at admission. + pub approver_pubkey: Vec, + /// Exact accepted native decision event. + pub decision_event_id: Vec, +} + +/// Persist a native request event, approval row, continuation and run wait in +/// the caller's lifecycle-fenced transaction. No partial wait can commit. +pub async fn save_wait( + tx: &mut Transaction<'_, Postgres>, + wait: &ApprovalWait, + request_event: &nostr::Event, +) -> Result { + let row = sqlx::query( + "SELECT r.status::text AS status, w.channel_id FROM workflow_runs r \ + JOIN workflows w ON (w.community_id,w.id)=(r.community_id,r.workflow_id) \ + WHERE r.community_id=$1 AND r.id=$2 AND r.workflow_id=$3 FOR UPDATE OF r", + ) + .bind(wait.community_id.as_uuid()) + .bind(wait.run_id) + .bind(wait.workflow_id) + .fetch_one(&mut **tx) + .await?; + if row.try_get::("status")? != "running" + || row.try_get::, _>("channel_id")? != Some(wait.channel_id) + || wait.timeout_secs <= 0 + || wait.timeout_secs > 604800 + || wait.reference.len() != 32 + { + return Err(DbError::InvalidData( + "run cannot enter approval wait".into(), + )); + } + let (stored, _) = crate::event::insert_event_in_transaction( + tx, + wait.community_id, + request_event, + Some(wait.channel_id), + ) + .await?; + sqlx::query( + "INSERT INTO workflow_approvals \ + (community_id,token,workflow_id,run_id,step_id,step_index,approver_spec,expires_at, \ + request_event_id,request_message,continuation) \ + VALUES ($1,$2,$3,$4,$5,$6,$7,clock_timestamp()+make_interval(secs=>$8),$9,$10,$11)", + ) + .bind(wait.community_id.as_uuid()) + .bind(&wait.reference) + .bind(wait.workflow_id) + .bind(wait.run_id) + .bind(&wait.step_id) + .bind(wait.step_index) + .bind(&wait.approver_spec) + .bind(wait.timeout_secs as f64) + .bind(request_event.id.as_bytes().as_slice()) + .bind(&wait.message) + .bind(&wait.continuation) + .execute(&mut **tx) + .await?; + sqlx::query( + "UPDATE workflow_runs SET status='waiting_approval',current_step=$3,execution_trace=$4 \ + WHERE community_id=$1 AND id=$2", + ) + .bind(wait.community_id.as_uuid()) + .bind(wait.run_id) + .bind(wait.step_index) + .bind(&wait.trace) + .execute(&mut **tx) + .await?; + Ok(stored) +} + +/// Decide and store the signed event in ONE transaction. Locks both approval and +/// run, and the current membership row, so concurrent revocation/decisions cannot +/// use stale membership or turn a previously granted wait into a second resume. +pub async fn decide( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + reference: &[u8], + event: &nostr::Event, + grant: bool, + allowed_channels: Option<&[Uuid]>, +) -> Result { + let row = sqlx::query( + "SELECT a.run_id,a.status::text AS status,a.decision_event_id,a.approver_spec, \ + a.expires_at,a.continuation,a.step_id,r.status::text AS run_status,r.current_step,a.step_index,w.channel_id \ + FROM workflow_approvals a JOIN workflow_runs r ON (r.community_id,r.id)=(a.community_id,a.run_id) \ + JOIN workflows w ON (w.community_id,w.id)=(a.community_id,a.workflow_id) \ + WHERE a.community_id=$1 AND a.token=$2 FOR UPDATE OF a,r", + ).bind(community.as_uuid()).bind(reference).fetch_optional(&mut **tx).await? + .ok_or_else(|| DbError::NotFound("approval".into()))?; + let run_id: Uuid = row.try_get("run_id")?; + let channel_id: Uuid = row.try_get("channel_id")?; + if allowed_channels.is_some_and(|ids| !ids.contains(&channel_id)) { + return Err(DbError::AccessDenied( + "approval is outside token channel scope".into(), + )); + } + let expected_kind = if grant { 46030 } else { 46031 }; + if event.kind.as_u16() != expected_kind + || event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.first().is_some_and(|key| key == "h") + && (parts.len() != 2 || parts[1] != channel_id.to_string()) + }) + { + return Err(DbError::InvalidData( + "decision kind or channel does not match approval".into(), + )); + } + let member: Option = sqlx::query_scalar( + "SELECT cm.role::text FROM channel_members cm JOIN channels c \ + ON (c.community_id,c.id)=(cm.community_id,cm.channel_id) \ + WHERE cm.community_id=$1 AND cm.channel_id=$2 AND cm.pubkey=$3 \ + AND cm.removed_at IS NULL AND c.deleted_at IS NULL AND c.archived_at IS NULL FOR SHARE OF cm,c", + ).bind(community.as_uuid()).bind(channel_id).bind(event.pubkey.to_bytes().as_slice()) + .fetch_optional(&mut **tx).await?; + let spec: String = row.try_get("approver_spec")?; + if member.is_none() || !approver_matches(&spec, &event.pubkey.to_hex()) { + return Err(DbError::AccessDenied( + "not a current designated channel approver".into(), + )); + } + let previous: Option> = row.try_get("decision_event_id")?; + let status: String = row.try_get("status")?; + if previous.as_deref() == Some(event.id.as_bytes().as_slice()) { + return Ok(DecisionReceipt { + run_id, + status, + duplicate: true, + }); + } + let now: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&mut **tx) + .await?; + if status != "pending" + || row.try_get::, _>("expires_at")? <= now + || row.try_get::("run_status")? != "waiting_approval" + || row.try_get::("current_step")? != row.try_get::("step_index")? + || row.try_get::, _>("continuation")?.is_none() + { + return Err(DbError::InvalidData( + "approval is resolved, expired, or lacks a continuation".into(), + )); + } + let (_, inserted) = + crate::event::insert_event_in_transaction(tx, community, event, Some(channel_id)).await?; + if !inserted { + return Err(DbError::InvalidData( + "decision event already used outside this approval".into(), + )); + } + let status = if grant { "granted" } else { "denied" }; + sqlx::query("UPDATE workflow_approvals SET status=$3::approval_status,approver_pubkey=$4,note=$5, \ + decision_event_id=$6,granted_at=CASE WHEN $3='granted' THEN clock_timestamp() ELSE NULL END, \ + denied_at=CASE WHEN $3='denied' THEN clock_timestamp() ELSE NULL END WHERE community_id=$1 AND token=$2") + .bind(community.as_uuid()).bind(reference).bind(status).bind(event.pubkey.to_bytes().as_slice()) + .bind(&event.content).bind(event.id.as_bytes().as_slice()).execute(&mut **tx).await?; + let decision_trace = serde_json::json!([{"step_id": row.try_get::("step_id")?, "status": status, + "output":{"approved":grant,"decision_event_id":event.id.to_hex()}, + "approval_ref":hex::encode(reference),"decision_event_id":event.id.to_hex(),"approver_pubkey":event.pubkey.to_hex()}]); + sqlx::query("UPDATE workflow_runs SET execution_trace=execution_trace || $3::jsonb, \ + status=CASE WHEN $4 THEN status ELSE 'cancelled'::run_status END, \ + error_code=CASE WHEN $4 THEN NULL ELSE 'approval_denied' END, \ + error_message=CASE WHEN $4 THEN NULL ELSE 'Workflow approval was denied' END, \ + completed_at=CASE WHEN $4 THEN NULL ELSE clock_timestamp() END WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()).bind(run_id).bind(decision_trace).bind(grant).execute(&mut **tx).await?; + Ok(DecisionReceipt { + run_id, + status: status.into(), + duplicate: false, + }) +} + +/// Supported approver formats deliberately exclude display-name/role guessing. +pub fn approver_matches(spec: &str, pubkey: &str) -> bool { + spec == "any" + || (spec.len() == 64 + && spec.bytes().all(|b| b.is_ascii_hexdigit()) + && spec.eq_ignore_ascii_case(pubkey)) +} + +impl Db { + /// Bounded writer-side recovery scan; each returned row is only a candidate. + pub async fn workflow_approval_candidates(&self) -> Result)>> { + let rows = sqlx::query("SELECT a.community_id,a.token FROM workflow_approvals a \ + JOIN workflow_runs r ON (r.community_id,r.id)=(a.community_id,a.run_id) \ + JOIN communities c ON c.id=a.community_id AND c.deletion_state='active' \ + WHERE a.continuation IS NOT NULL AND \ + ((a.status='pending' AND a.expires_at<=clock_timestamp() AND r.status='waiting_approval' AND r.current_step=a.step_index) \ + OR (a.status='granted' AND a.resume_claimed_at IS NULL AND r.status='waiting_approval' AND r.current_step=a.step_index) \ + OR (a.status='granted' AND a.resume_deadline_at<=clock_timestamp() AND r.status='running' AND r.current_step=a.step_index+1)) \ + ORDER BY a.created_at LIMIT 100") + .fetch_all(&self.pool).await?; + rows.into_iter() + .map(|r| { + Ok(( + CommunityId::from_uuid(r.try_get("community_id")?), + r.try_get("token")?, + )) + }) + .collect() + } + + /// Atomically expire an old wait or claim a granted continuation once. + /// A claimed execution is NEVER retried: after its deadline the durable + /// result is explicitly unknown/interrupted, as external effects may exist. + pub async fn claim_workflow_approval( + &self, + community: CommunityId, + reference: &[u8], + budget_secs: i64, + ) -> Result> { + if !(1..=3600).contains(&budget_secs) { + return Err(DbError::InvalidData("invalid resume budget".into())); + } + let mut tx = self.begin_event_write_transaction().await?; + crate::deletion::DeletionStore::new(self.pool.clone()) + .guard_transaction(&mut tx, community) + .await?; + let row = sqlx::query("SELECT a.*,a.status::text AS approval_status,r.status::text AS run_status,r.current_step,r.execution_trace \ + FROM workflow_approvals a JOIN workflow_runs r ON (r.community_id,r.id)=(a.community_id,a.run_id) \ + WHERE a.community_id=$1 AND a.token=$2 FOR UPDATE OF a,r") + .bind(community.as_uuid()).bind(reference).fetch_one(&mut *tx).await?; + let run_id: Uuid = row.try_get("run_id")?; + let step: i32 = row.try_get("step_index")?; + let current: i32 = row.try_get("current_step")?; + let run_status: String = row.try_get("run_status")?; + let status: String = row.try_get("approval_status")?; + let claimed: Option> = row.try_get("resume_claimed_at")?; + let deadline: Option> = row.try_get("resume_deadline_at")?; + let now: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&mut *tx) + .await?; + let expired = status == "pending" + && row.try_get::, _>("expires_at")? <= now + && run_status == "waiting_approval" + && current == step; + let interrupted = status == "granted" + && claimed.is_some() + && deadline.is_some_and(|d| d <= now) + && run_status == "running" + && current == step + 1; + if expired || interrupted { + let code = if expired { + "approval_expired" + } else { + "approval_resume_outcome_unknown" + }; + sqlx::query("UPDATE workflow_runs SET status='failed',error_code=$3,error_message=$4,completed_at=clock_timestamp() WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()).bind(run_id).bind(code) + .bind(if expired {"Workflow approval expired"}else{"Continuation interrupted; effects may have occurred and will not be replayed"}) + .execute(&mut *tx).await?; + if expired { + sqlx::query("UPDATE workflow_approvals SET status='expired' WHERE community_id=$1 AND token=$2").bind(community.as_uuid()).bind(reference).execute(&mut *tx).await?; + } + tx.commit().await?; + return Ok(None); + } + if status != "granted" + || claimed.is_some() + || run_status != "waiting_approval" + || current != step + { + return Ok(None); + } + let snapshot: Value = row + .try_get::, _>("continuation")? + .ok_or_else(|| DbError::InvalidData("missing continuation".into()))?; + sqlx::query("UPDATE workflow_approvals SET resume_claimed_at=clock_timestamp(),resume_deadline_at=clock_timestamp()+make_interval(secs=>$3) WHERE community_id=$1 AND token=$2") + .bind(community.as_uuid()).bind(reference).bind((budget_secs+30) as f64).execute(&mut *tx).await?; + sqlx::query("UPDATE workflow_runs SET status='running',current_step=$3 WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()).bind(run_id).bind(step+1).execute(&mut *tx).await?; + let claim = ApprovalContinuation { + approver_pubkey: row.try_get("approver_pubkey")?, + decision_event_id: row.try_get("decision_event_id")?, + community_id: community, + reference: reference.into(), + run_id, + workflow_id: row.try_get("workflow_id")?, + snapshot, + trace: row.try_get("execution_trace")?, + next_step: step + 1, + }; + tx.commit().await?; + Ok(Some(claim)) + } +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + use crate::workflow::RunStatus; + use nostr::{Event, EventBuilder, Keys, Kind, Tag}; + + struct Fixture { + db: Db, + pool: sqlx::PgPool, + wait: ApprovalWait, + owner: Keys, + other: Keys, + request: Event, + } + + async fn fixture() -> Fixture { + fixture_with_schema(true).await + } + + async fn fixture_with_schema(migrate: bool) -> Fixture { + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("Postgres"); + let db = Db::from_pool(pool.clone()); + if migrate && std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrations"); + } + let owner = Keys::generate(); + let other = Keys::generate(); + let community = db + .ensure_configured_community(&format!("approval-{}.example", Uuid::new_v4())) + .await + .expect("community") + .id; + for keys in [&owner, &other] { + db.ensure_user(community, &keys.public_key().to_bytes()) + .await + .expect("user"); + } + let channel = db + .create_channel( + community, + "approval-test", + buzz_core::channel::ChannelType::Stream, + buzz_core::channel::ChannelVisibility::Private, + None, + &owner.public_key().to_bytes(), + None, + ) + .await + .expect("channel"); + let workflow_id = db + .create_workflow( + community, + Some(channel.id), + &owner.public_key().to_bytes(), + "approval-test", + "{}", + &[1; 32], + ) + .await + .expect("workflow"); + let run_id = db + .create_workflow_run(community, workflow_id, None, None) + .await + .expect("run"); + db.update_workflow_run( + community, + run_id, + RunStatus::Running, + 0, + &serde_json::json!([]), + None, + ) + .await + .expect("running"); + let reference = super::super::hash_approval_token(&Uuid::new_v4().to_string()); + let request = EventBuilder::new(Kind::Custom(46010), "Approve exact operation") + .tags([Tag::parse(["d", &hex::encode(&reference)]).expect("tag")]) + .sign_with_keys(&owner) + .expect("signed request"); + let wait = ApprovalWait { + community_id: community, + workflow_id, + run_id, + channel_id: channel.id, + reference, + step_id: "review".into(), + step_index: 0, + approver_spec: owner.public_key().to_hex(), + message: "Approve exact operation".into(), + timeout_secs: 3600, + continuation: serde_json::json!({"definition_hash":"original","next_step":1}), + trace: serde_json::json!([{ "step_id":"review","status":"waiting_approval" }]), + }; + Fixture { + db, + pool, + wait, + owner, + other, + request, + } + } + + async fn suspend(f: &Fixture) { + let mut tx = f.db.begin_event_write_transaction().await.expect("tx"); + save_wait(&mut tx, &f.wait, &f.request) + .await + .expect("save atomic wait"); + tx.commit().await.expect("commit wait"); + } + + fn decision(f: &Fixture, keys: &Keys, grant: bool, note: &str) -> Event { + EventBuilder::new(Kind::Custom(if grant { 46030 } else { 46031 }), note) + .tags([Tag::parse(["d", &hex::encode(&f.wait.reference)]).expect("tag")]) + .sign_with_keys(keys) + .expect("signed decision") + } + + async fn commit_decision( + db: &Db, + community: CommunityId, + reference: &[u8], + event: &Event, + grant: bool, + ) -> Result { + let mut tx = db.begin_event_write_transaction().await?; + let receipt = decide(&mut tx, community, reference, event, grant, None).await?; + tx.commit().await?; + Ok(receipt) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn migration_schema_workflow_approval_legacy_wait_fails_closed() { + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("PG"); + crate::migration::run_migrations_through(&pool, 46) + .await + .expect("old schema"); + let f = fixture_with_schema(false).await; + f.db.create_approval(crate::workflow::CreateApprovalParams { + community_id: f.wait.community_id, + token: "legacy-test-reference", + workflow_id: f.wait.workflow_id, + run_id: f.wait.run_id, + step_id: "review", + step_index: 0, + approver_spec: "any", + expires_at: Utc::now() + chrono::Duration::hours(1), + }) + .await + .expect("legacy approval"); + f.db.update_workflow_run( + f.wait.community_id, + f.wait.run_id, + RunStatus::WaitingApproval, + 0, + &serde_json::json!([]), + None, + ) + .await + .expect("legacy wait"); + f.db.migrate().await.expect("additive approval migration"); + let run = + f.db.get_workflow_run(f.wait.community_id, f.wait.run_id) + .await + .expect("run"); + assert_eq!(run.status, RunStatus::Failed); + assert_eq!( + run.error_code.as_deref(), + Some("approval_continuation_unavailable") + ); + let approval = + f.db.get_approval(f.wait.community_id, "legacy-test-reference") + .await + .expect("legacy history retained"); + assert_eq!(approval.status, super::super::ApprovalStatus::Expired); + assert!(approval.request_event_id.is_none()); + let indexes:i64=sqlx::query_scalar("SELECT count(*) FROM pg_indexes WHERE tablename='workflow_approvals' AND indexname IN ('idx_workflow_approvals_decision_event','idx_workflow_approvals_recovery')").fetch_one(&pool).await.expect("indexes"); + assert_eq!(indexes, 2); + let native_fence:bool=sqlx::query_scalar("SELECT convalidated FROM pg_constraint WHERE conrelid='workflow_approvals'::regclass AND conname='workflow_approvals_native_decision_required'") + .fetch_one(&pool).await.expect("migrated native decision fence"); + assert!(native_fence); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_approval_wait_rolls_back_all_state_and_persists_snapshot() { + let f = fixture().await; + let mut tx = f.db.begin_event_write_transaction().await.expect("tx"); + save_wait(&mut tx, &f.wait, &f.request).await.expect("save"); + tx.rollback().await.expect("rollback"); + assert_eq!( + f.db.get_workflow_run(f.wait.community_id, f.wait.run_id) + .await + .expect("run") + .status, + RunStatus::Running + ); + assert!(f + .db + .get_approval_by_stored_hash(f.wait.community_id, &f.wait.reference) + .await + .is_err()); + assert!(f + .db + .get_event_by_id(f.wait.community_id, f.request.id.as_bytes()) + .await + .expect("event lookup") + .is_none()); + suspend(&f).await; + let row = + f.db.get_approval_by_stored_hash(f.wait.community_id, &f.wait.reference) + .await + .expect("approval"); + assert_eq!(row.request_event_id, Some(f.request.id.as_bytes().to_vec())); + assert_eq!( + row.request_message.as_deref(), + Some("Approve exact operation") + ); + let snapshot: Value = sqlx::query_scalar( + "SELECT continuation FROM workflow_approvals WHERE community_id=$1 AND token=$2", + ) + .bind(f.wait.community_id.as_uuid()) + .bind(&f.wait.reference) + .fetch_one(&f.pool) + .await + .expect("snapshot"); + assert_eq!(snapshot, f.wait.continuation); + assert_eq!( + f.db.get_workflow_run(f.wait.community_id, f.wait.run_id) + .await + .expect("run") + .status, + RunStatus::WaitingApproval + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_approval_saved_wait_survives_late_executor_finalization() { + let f = fixture().await; + suspend(&f).await; + for status in [RunStatus::Failed, RunStatus::Completed] { + assert!( + f.db.update_workflow_run( + f.wait.community_id, + f.wait.run_id, + status, + 99, + &serde_json::json!([{"late":"executor"}]), + Some(crate::workflow::WorkflowRunFailure { + code: "approval_resume_outcome_unknown", + message: "old executor finished after approval commit", + }), + ) + .await + .is_err(), + "late finalization must preserve the committed wait" + ); + let run = + f.db.get_workflow_run(f.wait.community_id, f.wait.run_id) + .await + .expect("run"); + assert_eq!(run.status, RunStatus::WaitingApproval); + assert_eq!(run.current_step, f.wait.step_index); + assert_eq!(run.execution_trace, f.wait.trace); + assert!(run.error_code.is_none()); + } + let event = decision(&f, &f.owner, true, "still resumable"); + commit_decision(&f.db, f.wait.community_id, &f.wait.reference, &event, true) + .await + .expect("decision survives late writer"); + assert!(f + .db + .claim_workflow_approval(f.wait.community_id, &f.wait.reference, 30) + .await + .expect("claim saved continuation") + .is_some()); + assert!( + f.db.update_workflow_run( + f.wait.community_id, + f.wait.run_id, + RunStatus::Failed, + f.wait.step_index, + &serde_json::json!([]), + None, + ) + .await + .is_err(), + "late pre-wait failure must not overwrite the next claimed continuation" + ); + let resumed = + f.db.get_workflow_run(f.wait.community_id, f.wait.run_id) + .await + .expect("resumed run"); + assert_eq!(resumed.status, RunStatus::Running); + assert_eq!(resumed.current_step, f.wait.step_index + 1); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_approval_membership_designation_and_token_scope_are_independent() { + let mut f = fixture().await; + f.wait.approver_spec = "any".into(); + suspend(&f).await; + let outsider = decision(&f, &f.other, true, "outsider"); + assert!(matches!( + commit_decision( + &f.db, + f.wait.community_id, + &f.wait.reference, + &outsider, + true + ) + .await, + Err(DbError::AccessDenied(_)) + )); + f.db.add_member( + f.wait.community_id, + f.wait.channel_id, + &f.other.public_key().to_bytes(), + buzz_core::channel::MemberRole::Member, + Some(&f.owner.public_key().to_bytes()), + ) + .await + .expect("member"); + sqlx::query( + "UPDATE workflow_approvals SET approver_spec=$3 WHERE community_id=$1 AND token=$2", + ) + .bind(f.wait.community_id.as_uuid()) + .bind(&f.wait.reference) + .bind(f.owner.public_key().to_hex()) + .execute(&f.pool) + .await + .expect("designated owner"); + assert!(matches!( + commit_decision( + &f.db, + f.wait.community_id, + &f.wait.reference, + &outsider, + true + ) + .await, + Err(DbError::AccessDenied(_)) + )); + let valid = decision(&f, &f.owner, true, "owner"); + let mut tx = f.db.begin_event_write_transaction().await.expect("tx"); + assert!(matches!( + decide( + &mut tx, + f.wait.community_id, + &f.wait.reference, + &valid, + true, + Some(&[]) + ) + .await, + Err(DbError::AccessDenied(_)) + )); + tx.rollback().await.expect("rollback"); + assert_eq!( + commit_decision(&f.db, f.wait.community_id, &f.wait.reference, &valid, true) + .await + .expect("authorized") + .status, + "granted" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_approval_legacy_writer_cannot_grant_or_deny_new_wait() { + let f = fixture().await; + suspend(&f).await; + for status in [ + crate::workflow::ApprovalStatus::Granted, + crate::workflow::ApprovalStatus::Denied, + ] { + let old_writer = + f.db.update_approval_by_stored_hash( + f.wait.community_id, + &f.wait.reference, + status, + Some(&f.owner.public_key().to_bytes()), + Some("old relay writer"), + ) + .await; + match old_writer { + Err(DbError::Sqlx(sqlx::Error::Database(error))) => { + assert_eq!(error.constraint(),Some("workflow_approvals_native_decision_required")); + } + other => panic!("legacy writer must fail at native decision fence before old relay can resume: {other:?}"), + } + assert_eq!( + f.db.get_approval_by_stored_hash(f.wait.community_id, &f.wait.reference) + .await + .expect("approval") + .status, + crate::workflow::ApprovalStatus::Pending + ); + assert_eq!( + f.db.get_workflow_run(f.wait.community_id, f.wait.run_id) + .await + .expect("run") + .status, + RunStatus::WaitingApproval + ); + } + let event = decision(&f, &f.owner, true, "new atomic writer"); + assert_eq!( + commit_decision(&f.db, f.wait.community_id, &f.wait.reference, &event, true) + .await + .expect("native writer still admitted") + .status, + "granted" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_approval_decision_rollback_is_atomic() { + let f = fixture().await; + suspend(&f).await; + let event = decision(&f, &f.owner, true, "approved"); + let mut tx = f.db.begin_event_write_transaction().await.expect("tx"); + decide( + &mut tx, + f.wait.community_id, + &f.wait.reference, + &event, + true, + None, + ) + .await + .expect("decision"); + tx.rollback().await.expect("rollback"); + assert_eq!( + f.db.get_approval_by_stored_hash(f.wait.community_id, &f.wait.reference) + .await + .expect("approval") + .status, + super::super::ApprovalStatus::Pending + ); + assert!(f + .db + .get_event_by_id(f.wait.community_id, event.id.as_bytes()) + .await + .expect("lookup") + .is_none()); + assert!(f + .db + .workflow_approval_candidates() + .await + .expect("candidates") + .iter() + .all(|(_, r)| r != &f.wait.reference)); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_approval_rejects_outsider_wrong_tenant_and_expired_decisions() { + let f = fixture().await; + suspend(&f).await; + let outsider = decision(&f, &f.other, true, "outsider"); + assert!(matches!( + commit_decision( + &f.db, + f.wait.community_id, + &f.wait.reference, + &outsider, + true + ) + .await, + Err(DbError::AccessDenied(_)) + )); + let valid = decision(&f, &f.owner, true, "owner"); + assert!(commit_decision( + &f.db, + CommunityId::from_uuid(Uuid::new_v4()), + &f.wait.reference, + &valid, + true + ) + .await + .is_err()); + sqlx::query("UPDATE workflow_approvals SET expires_at=clock_timestamp()-interval '1 second' WHERE community_id=$1 AND token=$2").bind(f.wait.community_id.as_uuid()).bind(&f.wait.reference).execute(&f.pool).await.expect("expire fixture"); + assert!( + commit_decision(&f.db, f.wait.community_id, &f.wait.reference, &valid, true) + .await + .is_err() + ); + assert!(f + .db + .claim_workflow_approval(f.wait.community_id, &f.wait.reference, 30) + .await + .expect("expire") + .is_none()); + let run = + f.db.get_workflow_run(f.wait.community_id, f.wait.run_id) + .await + .expect("run"); + assert_eq!(run.error_code.as_deref(), Some("approval_expired")); + assert_eq!( + f.db.get_approval_by_stored_hash(f.wait.community_id, &f.wait.reference) + .await + .expect("approval") + .status, + super::super::ApprovalStatus::Expired + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_approval_competing_decisions_have_one_winner_and_exact_replay() { + let f = fixture().await; + suspend(&f).await; + let grant = decision(&f, &f.owner, true, "grant"); + let deny = decision(&f, &f.owner, false, "deny"); + let (granted, denied) = tokio::join!( + commit_decision(&f.db, f.wait.community_id, &f.wait.reference, &grant, true), + commit_decision(&f.db, f.wait.community_id, &f.wait.reference, &deny, false) + ); + assert_ne!( + granted.is_ok(), + denied.is_ok(), + "exactly one competing decision commits" + ); + let (winner, approved) = if granted.is_ok() { + (&grant, true) + } else { + (&deny, false) + }; + let replay = commit_decision( + &f.db, + f.wait.community_id, + &f.wait.reference, + winner, + approved, + ) + .await + .expect("exact replay"); + assert!(replay.duplicate); + assert_eq!(replay.run_id, f.wait.run_id); + let event = + f.db.get_event_by_id(f.wait.community_id, winner.id.as_bytes()) + .await + .expect("lookup") + .expect("durable signed decision"); + assert_eq!(event.channel_id, Some(f.wait.channel_id)); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_approval_recovery_claims_once_and_never_replays_interrupted_effects() { + let f = fixture().await; + suspend(&f).await; + let event = decision(&f, &f.owner, true, "grant"); + commit_decision(&f.db, f.wait.community_id, &f.wait.reference, &event, true) + .await + .expect("commit"); + // Recreated Db handles model another process observing only committed state. + let restarted = Db::from_pool(f.pool.clone()); + let (a, b) = tokio::join!( + restarted.claim_workflow_approval(f.wait.community_id, &f.wait.reference, 30), + f.db.claim_workflow_approval(f.wait.community_id, &f.wait.reference, 30) + ); + assert_ne!(a.expect("first").is_some(), b.expect("second").is_some()); + sqlx::query("UPDATE workflow_approvals SET resume_deadline_at=clock_timestamp()-interval '1 second' WHERE community_id=$1 AND token=$2").bind(f.wait.community_id.as_uuid()).bind(&f.wait.reference).execute(&f.pool).await.expect("interrupt fixture"); + assert!(restarted + .claim_workflow_approval(f.wait.community_id, &f.wait.reference, 30) + .await + .expect("recover") + .is_none()); + let run = + f.db.get_workflow_run(f.wait.community_id, f.wait.run_id) + .await + .expect("run"); + assert_eq!( + run.error_code.as_deref(), + Some("approval_resume_outcome_unknown") + ); + assert!( + f.db.update_workflow_run( + f.wait.community_id, + f.wait.run_id, + RunStatus::Completed, + 2, + &serde_json::json!([]), + None + ) + .await + .is_err(), + "late writer must not overwrite unknown outcome" + ); + assert!(restarted + .claim_workflow_approval(f.wait.community_id, &f.wait.reference, 30) + .await + .expect("repeat recovery") + .is_none()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_approval_denial_is_terminal_before_returning_receipt() { + let f = fixture().await; + suspend(&f).await; + let event = decision(&f, &f.owner, false, "do not run"); + let receipt = commit_decision(&f.db, f.wait.community_id, &f.wait.reference, &event, false) + .await + .expect("deny"); + assert_eq!(receipt.status, "denied"); + let run = + f.db.get_workflow_run(f.wait.community_id, f.wait.run_id) + .await + .expect("run"); + assert_eq!(run.status, RunStatus::Cancelled); + assert_eq!(run.error_code.as_deref(), Some("approval_denied")); + assert!(f + .db + .claim_workflow_approval(f.wait.community_id, &f.wait.reference, 30) + .await + .expect("no continuation") + .is_none()); + } +} diff --git a/crates/buzz-db/src/task.rs b/crates/buzz-db/src/task.rs index c8b6018b461..61ee49aeeaa 100644 --- a/crates/buzz-db/src/task.rs +++ b/crates/buzz-db/src/task.rs @@ -18,7 +18,9 @@ //! transition neither of them made. use buzz_core::task::{status_change_action, TaskAction, TaskStatus}; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, SubsecRound, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use sqlx::{PgPool, Postgres, QueryBuilder, Row as _, Transaction}; use uuid::Uuid; @@ -36,14 +38,14 @@ macro_rules! task_columns { () => { "community_id, id, channel_id, created_by_pubkey, assignee_pubkey, \ parent_task_id, title, body, status, priority, source, source_ref, \ - due_at, done_at, archived_at, created_at, updated_at" + due_at, done_at, archived_at, created_at, updated_at, revision" }; } /// Columns selected for every [`TaskEventRecord`]. macro_rules! task_event_columns { () => { - "id, task_id, actor_pubkey, action, from_status, to_status, body, created_at" + "id, task_id, actor_pubkey, action, from_status, to_status, body, changes, created_at" }; } @@ -82,6 +84,8 @@ pub struct TaskRecord { pub created_at: DateTime, /// Last-modification timestamp. pub updated_at: DateTime, + /// Monotonic version, advanced only when persisted task fields change. + pub revision: i32, } /// One entry in a task's append-only history. @@ -101,6 +105,8 @@ pub struct TaskEventRecord { pub to_status: Option, /// Comment or summary text. pub body: Option, + /// Structured before/after values, absent for legacy events and comments. + pub changes: Option, /// When it happened. pub created_at: DateTime, } @@ -130,6 +136,19 @@ pub struct NewTask { pub due_at: Option>, } +/// Exclusive boundary for newest-modified-first task pagination. +/// +/// The full timestamp precision and id tie-breaker must both survive the wire. +/// This is a live keyset, not a snapshot: tasks modified between pages move +/// ahead of the cursor and can be found by refreshing the first page. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskCursor { + /// Last task's modification timestamp, with database precision. + pub updated_at: DateTime, + /// Last task's id, breaking equal-timestamp ties. + pub id: Uuid, +} + /// Filters for [`list_tasks`]. `None` means "do not filter on this field". #[derive(Debug, Clone, Default)] pub struct TaskFilter { @@ -147,6 +166,11 @@ pub struct TaskFilter { pub source_ref: Option, /// Include archived tasks. Archived tasks are hidden by default. pub include_archived: bool, + /// Caller-visible channels, applied before the limit. `Some([])` permits + /// only channel-less tasks; `None` leaves visibility to a trusted caller. + pub visible_channel_ids: Option>, + /// Return rows strictly after this boundary in newest-modified order. + pub before: Option, /// Maximum rows to return. pub limit: i64, } @@ -158,6 +182,8 @@ pub struct TaskFilter { /// The same distinction applies to `due_at`. #[derive(Debug, Clone, Default)] pub struct TaskPatch { + /// Optional optimistic concurrency guard; omission preserves unguarded writes. + pub expected_revision: Option, /// New status. pub status: Option, /// New title. @@ -204,6 +230,7 @@ fn parse_task_row(row: &sqlx::postgres::PgRow) -> Result { archived_at: row.try_get("archived_at")?, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, + revision: row.try_get("revision")?, }) } @@ -219,32 +246,35 @@ fn parse_task_event_row(row: &sqlx::postgres::PgRow) -> Result from_status: from_status.as_deref().map(parse_status).transpose()?, to_status: to_status.as_deref().map(parse_status).transpose()?, body: row.try_get("body")?, + changes: row.try_get("changes")?, created_at: row.try_get("created_at")?, }) } -/// Append one row to a task's history inside an open transaction. -/// -/// `transition` carries the `(from, to)` pair for -/// [`TaskAction::StatusChanged`] and is `None` for every other action — the -/// two ends are only ever meaningful together, so they travel together. +#[derive(Default)] +struct TaskEventContent<'a> { + transition: Option<(TaskStatus, TaskStatus)>, + body: Option<&'a str>, + changes: Option, +} + +/// Append a history row in the same transaction as its task mutation. async fn insert_task_event( tx: &mut Transaction<'_, Postgres>, community: CommunityId, task_id: Uuid, actor_pubkey: Option<&[u8]>, action: TaskAction, - transition: Option<(TaskStatus, TaskStatus)>, - body: Option<&str>, + content: TaskEventContent<'_>, ) -> Result { - let (from_status, to_status) = match transition { + let (from_status, to_status) = match content.transition { Some((from, to)) => (Some(from), Some(to)), None => (None, None), }; let row = sqlx::query(concat!( "INSERT INTO task_events \ - (community_id, task_id, actor_pubkey, action, from_status, to_status, body) \ - VALUES ($1, $2, $3, $4, $5, $6, $7) \ + (community_id, task_id, actor_pubkey, action, from_status, to_status, body, changes) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \ RETURNING ", task_event_columns!() )) @@ -254,7 +284,8 @@ async fn insert_task_event( .bind(action.as_str()) .bind(from_status.map(|status| status.as_str())) .bind(to_status.map(|status| status.as_str())) - .bind(body) + .bind(content.body) + .bind(content.changes) .fetch_one(&mut **tx) .await?; parse_task_event_row(&row) @@ -300,8 +331,15 @@ pub async fn create_task( task.id, new_task.created_by_pubkey.as_deref(), TaskAction::Created, - None, - None, + TaskEventContent { + changes: Some(json!({ + "title": {"from": null, "to": task.title}, + "assignee": {"from": null, "to": task.assignee_pubkey.as_ref().map(hex::encode)}, + "priority": {"from": null, "to": task.priority}, + "due_at": {"from": null, "to": task.due_at}, + })), + ..TaskEventContent::default() + }, ) .await?; @@ -355,6 +393,18 @@ pub async fn list_tasks( if !filter.include_archived { builder.push(" AND archived_at IS NULL"); } + if let Some(channels) = &filter.visible_channel_ids { + builder.push(" AND (channel_id IS NULL OR channel_id = ANY("); + builder.push_bind(channels); + builder.push("))"); + } + if let Some(cursor) = &filter.before { + builder.push(" AND (updated_at, id) < ("); + builder.push_bind(cursor.updated_at); + builder.push(", "); + builder.push_bind(cursor.id); + builder.push(")"); + } builder.push(" ORDER BY updated_at DESC, id DESC LIMIT "); builder.push_bind(filter.limit); @@ -419,11 +469,25 @@ pub async fn update_task( .await? .ok_or_else(|| DbError::NotFound(format!("task {id}")))?; let current = parse_task_row(¤t)?; + if let Some(expected) = patch.expected_revision { + if expected != current.revision { + return Err(DbError::StaleRevision { + task_id: id, + expected, + actual: current.revision, + }); + } + } let new_status = patch.status.unwrap_or(current.status); let new_title = patch.title.clone().unwrap_or_else(|| current.title.clone()); let new_priority = patch.priority.unwrap_or(current.priority); - let new_due_at = patch.due_at.unwrap_or(current.due_at); + // PostgreSQL stores microseconds; normalize before comparison so a client + // retry with nanoseconds neither inflates history nor records phantom digits. + let new_due_at = patch + .due_at + .unwrap_or(current.due_at) + .map(|value| value.trunc_subsecs(6)); let new_assignee = patch .assignee_pubkey .clone() @@ -436,9 +500,19 @@ pub async fn update_task( None }; + if new_status == current.status + && new_title == current.title + && new_priority == current.priority + && new_due_at == current.due_at + && new_assignee == current.assignee_pubkey + { + tx.commit().await?; + return Ok(current); + } + let row = sqlx::query(concat!( "UPDATE tasks SET status = $3, title = $4, priority = $5, due_at = $6, \ - assignee_pubkey = $7, done_at = $8, updated_at = NOW() \ + assignee_pubkey = $7, done_at = $8 \ WHERE community_id = $1 AND id = $2 \ RETURNING ", task_columns!() @@ -462,8 +536,10 @@ pub async fn update_task( id, actor_pubkey, action, - Some((current.status, new_status)), - None, + TaskEventContent { + transition: Some((current.status, new_status)), + ..TaskEventContent::default() + }, ) .await?; } @@ -474,8 +550,11 @@ pub async fn update_task( id, actor_pubkey, TaskAction::TitleChanged, - None, - Some(&new_title), + TaskEventContent { + body: Some(&new_title), + changes: Some(json!({"title": {"from": current.title, "to": new_title}})), + ..TaskEventContent::default() + }, ) .await?; } @@ -486,12 +565,45 @@ pub async fn update_task( id, actor_pubkey, TaskAction::Assigned, - None, - None, + TaskEventContent { + changes: Some(json!({"assignee": { + "from": current.assignee_pubkey.as_ref().map(hex::encode), + "to": new_assignee.as_ref().map(hex::encode), + }})), + ..TaskEventContent::default() + }, ) .await?; } + for (action, changes) in [ + ( + TaskAction::PriorityChanged, + (new_priority != current.priority) + .then(|| json!({"priority": {"from": current.priority, "to": new_priority}})), + ), + ( + TaskAction::DueAtChanged, + (new_due_at != current.due_at) + .then(|| json!({"due_at": {"from": current.due_at, "to": new_due_at}})), + ), + ] { + if let Some(changes) = changes { + insert_task_event( + &mut tx, + community, + id, + actor_pubkey, + action, + TaskEventContent { + changes: Some(changes), + ..TaskEventContent::default() + }, + ) + .await?; + } + } + tx.commit().await?; Ok(updated) } @@ -529,8 +641,10 @@ pub async fn append_task_event( task_id, actor_pubkey, action, - None, - body, + TaskEventContent { + body, + ..TaskEventContent::default() + }, ) .await .map_err(|error| match &error { @@ -555,6 +669,15 @@ mod tests { assert!(TaskPatch::default().is_empty()); } + #[test] + fn a_guard_only_patch_is_empty() { + assert!(TaskPatch { + expected_revision: Some(0), + ..TaskPatch::default() + } + .is_empty()); + } + #[test] fn clearing_a_field_is_not_an_empty_patch() { // `Some(None)` means "unassign", which is a real change. Treating it as @@ -589,25 +712,6 @@ mod tests { "duplicate column in projection" ); } - - // ── Live-Postgres integration coverage ────────────────────────────────── - // - // `#[ignore]`d, exactly like every other Postgres-backed test in this - // crate: `just test-unit` runs `-p buzz-db --lib`, which skips them, and - // `just test` (Docker Postgres + Redis) is what turns them on. Run one - // directly with: - // - // cargo test -p buzz-db --lib crate::task::tests -- --ignored - // - // against a database that has migration 0033 applied. - - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - - pub(super) fn test_database_url() -> String { - std::env::var("BUZZ_TEST_DATABASE_URL") - .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| TEST_DB_URL.to_owned()) - } } use crate::Db; @@ -687,374 +791,5 @@ impl Db { } #[cfg(test)] -mod postgres_tests { - use super::tests::test_database_url; - use super::*; - async fn setup_pool() -> PgPool { - PgPool::connect(&test_database_url()) - .await - .expect("connect to test DB") - } - - async fn make_test_community(pool: &PgPool) -> CommunityId { - let id = Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(id) - .bind(format!("task-test-{}.example", id.simple())) - .execute(pool) - .await - .expect("insert test community"); - CommunityId::from_uuid(id) - } - - async fn make_test_user(pool: &PgPool, community: CommunityId, seed: u8) -> Vec { - let pubkey = vec![seed; 32]; - crate::user::ensure_user(pool, community, &pubkey) - .await - .expect("ensure test user"); - pubkey - } - - async fn delete_test_community(pool: &PgPool, community: CommunityId) { - for table in ["task_events", "tasks", "users"] { - sqlx::query(sqlx::AssertSqlSafe(format!( - "DELETE FROM {table} WHERE community_id = $1" - ))) - .bind(community.as_uuid()) - .execute(pool) - .await - .expect("delete test rows"); - } - sqlx::query("DELETE FROM communities WHERE id = $1") - .bind(community.as_uuid()) - .execute(pool) - .await - .expect("delete test community"); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn create_then_list_then_get_round_trips_a_task_and_its_history() { - let pool = setup_pool().await; - let community = make_test_community(&pool).await; - let creator = make_test_user(&pool, community, 0x11).await; - - let created = create_task( - &pool, - community, - NewTask { - created_by_pubkey: Some(creator.clone()), - title: "ship the task system".to_owned(), - body: Some("phase 1".to_owned()), - priority: 5, - source: Some("claude".to_owned()), - ..NewTask::default() - }, - ) - .await - .expect("create task"); - - assert_eq!(created.title, "ship the task system"); - assert_eq!(created.status, TaskStatus::Todo); - assert_eq!(created.priority, 5); - assert_eq!(created.done_at, None); - assert_eq!( - created.created_by_pubkey.as_deref(), - Some(creator.as_slice()) - ); - - let listed = list_tasks( - &pool, - community, - &TaskFilter { - limit: 10, - ..TaskFilter::default() - }, - ) - .await - .expect("list tasks"); - assert_eq!(listed, vec![created.clone()]); - - let fetched = get_task(&pool, community, created.id) - .await - .expect("get task"); - assert_eq!(fetched, created); - - // create_task commits the task and its opening history entry together. - let events = list_task_events(&pool, community, created.id) - .await - .expect("list events"); - assert_eq!(events.len(), 1); - assert_eq!(events[0].action, TaskAction::Created); - - delete_test_community(&pool, community).await; - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn a_task_is_findable_by_its_source_ref() { - let pool = setup_pool().await; - let community = make_test_community(&pool).await; - let creator = make_test_user(&pool, community, 0x21).await; - - let wanted = create_task( - &pool, - community, - NewTask { - created_by_pubkey: Some(creator.clone()), - title: "from the thread we care about".to_owned(), - source: Some("app".to_owned()), - source_ref: Some("thread-head-aaa".to_owned()), - ..NewTask::default() - }, - ) - .await - .expect("create linked task"); - - let other = create_task( - &pool, - community, - NewTask { - created_by_pubkey: Some(creator.clone()), - title: "from a different thread".to_owned(), - source: Some("app".to_owned()), - source_ref: Some("thread-head-bbb".to_owned()), - ..NewTask::default() - }, - ) - .await - .expect("create unrelated task"); - - // Exact equality: the reader queries the same key the writer wrote. - let found = list_tasks( - &pool, - community, - &TaskFilter { - source_ref: Some("thread-head-aaa".to_owned()), - limit: 10, - ..TaskFilter::default() - }, - ) - .await - .expect("list by source_ref"); - assert_eq!(found, vec![wanted.clone()]); - - // An unknown reference is an empty page, never an error and never a - // fallback to "everything". - let missing = list_tasks( - &pool, - community, - &TaskFilter { - source_ref: Some("thread-head-does-not-exist".to_owned()), - limit: 10, - ..TaskFilter::default() - }, - ) - .await - .expect("list unknown source_ref"); - assert!(missing.is_empty()); - - // Omitting the filter must keep today's behaviour: both tasks. - let unfiltered = list_tasks( - &pool, - community, - &TaskFilter { - limit: 10, - ..TaskFilter::default() - }, - ) - .await - .expect("list unfiltered"); - assert_eq!(unfiltered.len(), 2); - assert!(unfiltered.contains(&wanted)); - assert!(unfiltered.contains(&other)); - - delete_test_community(&pool, community).await; - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn a_status_change_sets_done_at_and_appends_exactly_one_event() { - let pool = setup_pool().await; - let community = make_test_community(&pool).await; - let creator = make_test_user(&pool, community, 0x22).await; - - let task = create_task( - &pool, - community, - NewTask { - created_by_pubkey: Some(creator.clone()), - title: "finish it".to_owned(), - ..NewTask::default() - }, - ) - .await - .expect("create task"); - - let done = update_task( - &pool, - community, - task.id, - &TaskPatch { - status: Some(TaskStatus::Done), - ..TaskPatch::default() - }, - Some(&creator), - ) - .await - .expect("mark done"); - assert_eq!(done.status, TaskStatus::Done); - assert!( - done.done_at.is_some(), - "done_at is derived from the status, not supplied by the caller" - ); - - let events = list_task_events(&pool, community, task.id) - .await - .expect("list events"); - assert_eq!(events.len(), 2, "created + status_changed"); - assert_eq!(events[1].action, TaskAction::StatusChanged); - assert_eq!(events[1].from_status, Some(TaskStatus::Todo)); - assert_eq!(events[1].to_status, Some(TaskStatus::Done)); - - // Restating the same status is idempotent: no second event. - update_task( - &pool, - community, - task.id, - &TaskPatch { - status: Some(TaskStatus::Done), - ..TaskPatch::default() - }, - Some(&creator), - ) - .await - .expect("restate done"); - let events = list_task_events(&pool, community, task.id) - .await - .expect("list events again"); - assert_eq!(events.len(), 2, "restating a status must append nothing"); - - // Reopening clears done_at, keeping chk_tasks_done_at_matches_status - // satisfiable. - let reopened = update_task( - &pool, - community, - task.id, - &TaskPatch { - status: Some(TaskStatus::Todo), - ..TaskPatch::default() - }, - Some(&creator), - ) - .await - .expect("reopen"); - assert_eq!(reopened.done_at, None); - - delete_test_community(&pool, community).await; - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn a_task_id_is_invisible_to_another_community() { - let pool = setup_pool().await; - let owner = make_test_community(&pool).await; - let stranger = make_test_community(&pool).await; - let creator = make_test_user(&pool, owner, 0x33).await; - - let task = create_task( - &pool, - owner, - NewTask { - created_by_pubkey: Some(creator), - title: "tenant-private".to_owned(), - ..NewTask::default() - }, - ) - .await - .expect("create task"); - - // The bare id is not a capability: presented against another tenant it - // reads as absent, never as the owner's row. - assert!(matches!( - get_task(&pool, stranger, task.id).await, - Err(DbError::NotFound(_)) - )); - assert!(matches!( - append_task_event( - &pool, - stranger, - task.id, - None, - TaskAction::Commented, - Some("leak?") - ) - .await, - Err(DbError::NotFound(_)) - )); - - delete_test_community(&pool, owner).await; - delete_test_community(&pool, stranger).await; - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn a_task_keeps_at_most_one_persisted_summary() { - let pool = setup_pool().await; - let community = make_test_community(&pool).await; - let actor = make_test_user(&pool, community, 0x44).await; - - let task = create_task( - &pool, - community, - NewTask { - created_by_pubkey: Some(actor.clone()), - title: "summarize me".to_owned(), - ..NewTask::default() - }, - ) - .await - .expect("create task"); - - append_task_event( - &pool, - community, - task.id, - Some(&actor), - TaskAction::SummaryPersisted, - Some("first summary"), - ) - .await - .expect("first summary"); - - let second = append_task_event( - &pool, - community, - task.id, - Some(&actor), - TaskAction::SummaryPersisted, - Some("second summary"), - ) - .await; - assert!( - matches!(second, Err(DbError::InvalidData(_))), - "the partial unique index must reject a second summary, got {second:?}" - ); - - // Ordinary comments stay unbounded. - for _ in 0..2 { - append_task_event( - &pool, - community, - task.id, - Some(&actor), - TaskAction::Commented, - Some("a comment"), - ) - .await - .expect("comment"); - } - - delete_test_community(&pool, community).await; - } -} +#[path = "task/postgres_tests.rs"] +mod postgres_tests; diff --git a/crates/buzz-db/src/task/postgres_tests.rs b/crates/buzz-db/src/task/postgres_tests.rs new file mode 100644 index 00000000000..99e8f7947eb --- /dev/null +++ b/crates/buzz-db/src/task/postgres_tests.rs @@ -0,0 +1,1033 @@ +use super::*; +async fn setup_pool() -> PgPool { + PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB") +} + +async fn make_test_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("task-test-{}.example", id.simple())) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) +} + +async fn make_test_user(pool: &PgPool, community: CommunityId, seed: u8) -> Vec { + let pubkey = vec![seed; 32]; + crate::user::ensure_user(pool, community, &pubkey) + .await + .expect("ensure test user"); + pubkey +} + +async fn delete_test_community(pool: &PgPool, community: CommunityId) { + for table in ["task_events", "tasks", "users"] { + sqlx::query(sqlx::AssertSqlSafe(format!( + "DELETE FROM {table} WHERE community_id = $1" + ))) + .bind(community.as_uuid()) + .execute(pool) + .await + .expect("delete test rows"); + } + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community.as_uuid()) + .execute(pool) + .await + .expect("delete test community"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn create_then_list_then_get_round_trips_a_task_and_its_history() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let creator = make_test_user(&pool, community, 0x11).await; + + let created = create_task( + &pool, + community, + NewTask { + created_by_pubkey: Some(creator.clone()), + title: "ship the task system".to_owned(), + body: Some("phase 1".to_owned()), + priority: 5, + source: Some("claude".to_owned()), + ..NewTask::default() + }, + ) + .await + .expect("create task"); + + assert_eq!(created.title, "ship the task system"); + assert_eq!(created.status, TaskStatus::Todo); + assert_eq!(created.priority, 5); + assert_eq!(created.done_at, None); + assert_eq!( + created.created_by_pubkey.as_deref(), + Some(creator.as_slice()) + ); + + let listed = list_tasks( + &pool, + community, + &TaskFilter { + limit: 10, + ..TaskFilter::default() + }, + ) + .await + .expect("list tasks"); + assert_eq!(listed, vec![created.clone()]); + + let fetched = get_task(&pool, community, created.id) + .await + .expect("get task"); + assert_eq!(fetched, created); + + // create_task commits the task and its opening history entry together. + let events = list_task_events(&pool, community, created.id) + .await + .expect("list events"); + assert_eq!(events.len(), 1); + assert_eq!(events[0].action, TaskAction::Created); + + delete_test_community(&pool, community).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn a_task_is_findable_by_its_source_ref() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let creator = make_test_user(&pool, community, 0x21).await; + + let wanted = create_task( + &pool, + community, + NewTask { + created_by_pubkey: Some(creator.clone()), + title: "from the thread we care about".to_owned(), + source: Some("app".to_owned()), + source_ref: Some("thread-head-aaa".to_owned()), + ..NewTask::default() + }, + ) + .await + .expect("create linked task"); + + let other = create_task( + &pool, + community, + NewTask { + created_by_pubkey: Some(creator.clone()), + title: "from a different thread".to_owned(), + source: Some("app".to_owned()), + source_ref: Some("thread-head-bbb".to_owned()), + ..NewTask::default() + }, + ) + .await + .expect("create unrelated task"); + + // Exact equality: the reader queries the same key the writer wrote. + let found = list_tasks( + &pool, + community, + &TaskFilter { + source_ref: Some("thread-head-aaa".to_owned()), + limit: 10, + ..TaskFilter::default() + }, + ) + .await + .expect("list by source_ref"); + assert_eq!(found, vec![wanted.clone()]); + + // An unknown reference is an empty page, never an error and never a + // fallback to "everything". + let missing = list_tasks( + &pool, + community, + &TaskFilter { + source_ref: Some("thread-head-does-not-exist".to_owned()), + limit: 10, + ..TaskFilter::default() + }, + ) + .await + .expect("list unknown source_ref"); + assert!(missing.is_empty()); + + // Omitting the filter must keep today's behaviour: both tasks. + let unfiltered = list_tasks( + &pool, + community, + &TaskFilter { + limit: 10, + ..TaskFilter::default() + }, + ) + .await + .expect("list unfiltered"); + assert_eq!(unfiltered.len(), 2); + assert!(unfiltered.contains(&wanted)); + assert!(unfiltered.contains(&other)); + + delete_test_community(&pool, community).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn a_status_change_sets_done_at_and_appends_exactly_one_event() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let creator = make_test_user(&pool, community, 0x22).await; + + let task = create_task( + &pool, + community, + NewTask { + created_by_pubkey: Some(creator.clone()), + title: "finish it".to_owned(), + ..NewTask::default() + }, + ) + .await + .expect("create task"); + + let done = update_task( + &pool, + community, + task.id, + &TaskPatch { + status: Some(TaskStatus::Done), + ..TaskPatch::default() + }, + Some(&creator), + ) + .await + .expect("mark done"); + assert_eq!(done.status, TaskStatus::Done); + assert!( + done.done_at.is_some(), + "done_at is derived from the status, not supplied by the caller" + ); + + let events = list_task_events(&pool, community, task.id) + .await + .expect("list events"); + assert_eq!(events.len(), 2, "created + status_changed"); + assert_eq!(events[1].action, TaskAction::StatusChanged); + assert_eq!(events[1].from_status, Some(TaskStatus::Todo)); + assert_eq!(events[1].to_status, Some(TaskStatus::Done)); + + // Restating the same status is idempotent: no second event. + update_task( + &pool, + community, + task.id, + &TaskPatch { + status: Some(TaskStatus::Done), + ..TaskPatch::default() + }, + Some(&creator), + ) + .await + .expect("restate done"); + let events = list_task_events(&pool, community, task.id) + .await + .expect("list events again"); + assert_eq!(events.len(), 2, "restating a status must append nothing"); + + // Reopening clears done_at, keeping chk_tasks_done_at_matches_status + // satisfiable. + let reopened = update_task( + &pool, + community, + task.id, + &TaskPatch { + status: Some(TaskStatus::Todo), + ..TaskPatch::default() + }, + Some(&creator), + ) + .await + .expect("reopen"); + assert_eq!(reopened.done_at, None); + + delete_test_community(&pool, community).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn a_task_id_is_invisible_to_another_community() { + let pool = setup_pool().await; + let owner = make_test_community(&pool).await; + let stranger = make_test_community(&pool).await; + let creator = make_test_user(&pool, owner, 0x33).await; + + let task = create_task( + &pool, + owner, + NewTask { + created_by_pubkey: Some(creator), + title: "tenant-private".to_owned(), + ..NewTask::default() + }, + ) + .await + .expect("create task"); + + // The bare id is not a capability: presented against another tenant it + // reads as absent, never as the owner's row. + assert!(matches!( + get_task(&pool, stranger, task.id).await, + Err(DbError::NotFound(_)) + )); + assert!(matches!( + append_task_event( + &pool, + stranger, + task.id, + None, + TaskAction::Commented, + Some("leak?") + ) + .await, + Err(DbError::NotFound(_)) + )); + + delete_test_community(&pool, owner).await; + delete_test_community(&pool, stranger).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn a_task_keeps_at_most_one_persisted_summary() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let actor = make_test_user(&pool, community, 0x44).await; + + let task = create_task( + &pool, + community, + NewTask { + created_by_pubkey: Some(actor.clone()), + title: "summarize me".to_owned(), + ..NewTask::default() + }, + ) + .await + .expect("create task"); + + append_task_event( + &pool, + community, + task.id, + Some(&actor), + TaskAction::SummaryPersisted, + Some("first summary"), + ) + .await + .expect("first summary"); + + let second = append_task_event( + &pool, + community, + task.id, + Some(&actor), + TaskAction::SummaryPersisted, + Some("second summary"), + ) + .await; + assert!( + matches!(second, Err(DbError::InvalidData(_))), + "the partial unique index must reject a second summary, got {second:?}" + ); + + // Ordinary comments stay unbounded. + for _ in 0..2 { + append_task_event( + &pool, + community, + task.id, + Some(&actor), + TaskAction::Commented, + Some("a comment"), + ) + .await + .expect("comment"); + } + + delete_test_community(&pool, community).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn assignment_and_schedule_history_preserves_before_after_and_noop_retry() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let actor = make_test_user(&pool, community, 0x51).await; + let assignee = make_test_user(&pool, community, 0x52).await; + let due: DateTime = "2026-09-09T14:15:16.123456Z".parse().expect("date"); + let task = create_task( + &pool, + community, + NewTask { + created_by_pubkey: Some(actor.clone()), + title: "original".into(), + ..NewTask::default() + }, + ) + .await + .expect("create"); + let patch = TaskPatch { + title: Some("renamed".into()), + assignee_pubkey: Some(Some(assignee.clone())), + priority: Some(8), + due_at: Some(Some(due + chrono::TimeDelta::nanoseconds(789))), + ..TaskPatch::default() + }; + let updated = update_task(&pool, community, task.id, &patch, Some(&actor)) + .await + .expect("update"); + assert_eq!(updated.assignee_pubkey, Some(assignee.clone())); + assert_eq!(updated.priority, 8); + assert_eq!(updated.due_at, Some(due)); + let events = list_task_events(&pool, community, task.id) + .await + .expect("history"); + assert_eq!(events.len(), 5); + assert_eq!( + events[0].changes.as_ref().expect("initial snapshot")["priority"], + json!({"from": null, "to": 0}) + ); + for (action, changes) in [ + ( + TaskAction::TitleChanged, + json!({"title": {"from": "original", "to": "renamed"}}), + ), + ( + TaskAction::Assigned, + json!({"assignee": {"from": null, "to": hex::encode(&assignee)}}), + ), + ( + TaskAction::PriorityChanged, + json!({"priority": {"from": 0, "to": 8}}), + ), + ( + TaskAction::DueAtChanged, + json!({"due_at": {"from": null, "to": due}}), + ), + ] { + let event = events + .iter() + .find(|e| e.action == action) + .expect("field history"); + assert_eq!(event.changes, Some(changes)); + assert_eq!(event.actor_pubkey, Some(actor.clone())); + } + // A transport retry must change neither history nor pagination order. + let retried = update_task(&pool, community, task.id, &patch, Some(&actor)) + .await + .expect("retry"); + assert_eq!(retried, updated); + assert_eq!( + list_task_events(&pool, community, task.id) + .await + .expect("retry history"), + events + ); + + update_task( + &pool, + community, + task.id, + &TaskPatch { + assignee_pubkey: Some(Some(actor.clone())), + ..TaskPatch::default() + }, + Some(&actor), + ) + .await + .expect("reassign"); + update_task( + &pool, + community, + task.id, + &TaskPatch { + assignee_pubkey: Some(None), + due_at: Some(None), + ..TaskPatch::default() + }, + Some(&actor), + ) + .await + .expect("clear assignment and deadline"); + let events = list_task_events(&pool, community, task.id) + .await + .expect("cleared history"); + assert_eq!( + events[5].changes, + Some(json!({"assignee": {"from": hex::encode(&assignee), "to": hex::encode(&actor)}})) + ); + assert_eq!( + events[6].changes, + Some(json!({"assignee": {"from": hex::encode(&actor), "to": null}})) + ); + assert_eq!( + events[7].changes, + Some(json!({"due_at": {"from": due, "to": null}})) + ); + delete_test_community(&pool, community).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn task_mutation_rolls_back_when_its_history_cannot_be_written() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let actor = make_test_user(&pool, community, 0x53).await; + let task = create_task( + &pool, + community, + NewTask { + created_by_pubkey: Some(actor.clone()), + title: "atomic mutation".into(), + ..NewTask::default() + }, + ) + .await + .expect("create"); + // Deliberately fail the history insert after the task UPDATE. A user FK + // violation is an existing production constraint; no test-only write seam. + let result = update_task( + &pool, + community, + task.id, + &TaskPatch { + priority: Some(99), + ..TaskPatch::default() + }, + Some(&[0xfe; 32]), + ) + .await; + assert!( + result.is_err(), + "invalid audit actor must reject the mutation" + ); + assert_eq!( + get_task(&pool, community, task.id) + .await + .expect("persisted task"), + task + ); + assert_eq!( + list_task_events(&pool, community, task.id) + .await + .expect("history") + .len(), + 1 + ); + delete_test_community(&pool, community).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn concurrent_schedule_updates_record_the_locked_before_image() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let actor = make_test_user(&pool, community, 0x54).await; + let task = create_task( + &pool, + community, + NewTask { + created_by_pubkey: Some(actor.clone()), + title: "concurrent priority".into(), + ..NewTask::default() + }, + ) + .await + .expect("create"); + let first = TaskPatch { + priority: Some(5), + ..TaskPatch::default() + }; + let second = TaskPatch { + priority: Some(7), + ..TaskPatch::default() + }; + let (a, b) = tokio::join!( + update_task(&pool, community, task.id, &first, Some(&actor)), + update_task(&pool, community, task.id, &second, Some(&actor)), + ); + a.expect("first mutation"); + b.expect("second mutation"); + let events = list_task_events(&pool, community, task.id) + .await + .expect("history"); + // The normal history read must preserve the actual mutation order. + let changes: Vec<_> = events + .iter() + .filter(|e| e.action == TaskAction::PriorityChanged) + .map(|e| &e.changes.as_ref().expect("structured history")["priority"]) + .collect(); + assert_eq!(changes.len(), 2); + assert_eq!(changes[0]["from"], 0); + assert_eq!(changes[1]["from"], changes[0]["to"]); + assert_eq!( + changes[1]["to"], + get_task(&pool, community, task.id) + .await + .expect("task") + .priority + ); + delete_test_community(&pool, community).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn visibility_precedes_limit_and_cursor_keeps_equal_timestamp_rows() { + use buzz_core::channel::{ChannelType, ChannelVisibility}; + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let actor = make_test_user(&pool, community, 0x55).await; + let channel = crate::channel::create_channel( + &pool, + community, + "hidden", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &actor, + None, + ) + .await + .expect("channel"); + // INSERT fixed timestamps: revision owns updated_at on UPDATE, including + // direct SQL writers, so a fixture must not bypass that production trigger. + let same_time: DateTime = "2026-09-07T10:11:12.123456Z".parse().expect("timestamp"); + let mut visible = Vec::new(); + for title in ["visible one", "visible two", "visible three"] { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO tasks (community_id, id, title, updated_at) VALUES ($1, $2, $3, $4)", + ) + .bind(community.as_uuid()) + .bind(id) + .bind(title) + .bind(same_time) + .execute(&pool) + .await + .expect("visible fixture task"); + visible.push( + get_task(&pool, community, id) + .await + .expect("read visible task"), + ); + } + for _ in 0..3 { + create_task( + &pool, + community, + NewTask { + title: "hidden newer".into(), + channel_id: Some(channel.id), + ..NewTask::default() + }, + ) + .await + .expect("hidden task"); + } + visible.sort_by_key(|task| std::cmp::Reverse(task.id)); + let mut filter = TaskFilter { + visible_channel_ids: Some(vec![]), + limit: 2, + ..TaskFilter::default() + }; + let first = list_tasks(&pool, community, &filter) + .await + .expect("first visible page"); + assert_eq!( + first.iter().map(|t| t.id).collect::>(), + visible[..2].iter().map(|t| t.id).collect::>() + ); + let last = first.last().expect("first page tail"); + filter.before = Some(TaskCursor { + updated_at: last.updated_at, + id: last.id, + }); + let second = list_tasks(&pool, community, &filter) + .await + .expect("second page"); + assert_eq!( + second.iter().map(|t| t.id).collect::>(), + vec![visible[2].id] + ); + filter.before = Some(TaskCursor { + updated_at: second[0].updated_at, + id: second[0].id, + }); + assert!(list_tasks(&pool, community, &filter) + .await + .expect("end of pages") + .is_empty()); + filter.before = None; + filter.visible_channel_ids = Some(vec![channel.id]); + assert!(list_tasks(&pool, community, &filter) + .await + .expect("member page") + .iter() + .all(|t| t.channel_id == Some(channel.id))); + // The per-test database is discarded by the runner, but normal cleanup + // also leaves this scenario reusable in the focused local invocation. + sqlx::query("DELETE FROM task_events WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .expect("events cleanup"); + sqlx::query("DELETE FROM tasks WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .expect("tasks cleanup"); + sqlx::query("DELETE FROM channel_members WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .expect("members cleanup"); + sqlx::query("DELETE FROM channels WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .expect("channels cleanup"); + delete_test_community(&pool, community).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_task_history_upgrade_preserves_legacy_rows() { + let pool = setup_pool().await; + crate::migration::run_migrations_through(&pool, 46) + .await + .expect("legacy migrations"); + let community = make_test_community(&pool).await; + let id: Uuid = sqlx::query_scalar( + "INSERT INTO tasks (community_id, title) VALUES ($1, 'legacy task') RETURNING id", + ) + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("legacy task"); + sqlx::query( + "INSERT INTO task_events (community_id, task_id, action) VALUES ($1, $2, 'assigned')", + ) + .bind(community.as_uuid()) + .bind(id) + .execute(&pool) + .await + .expect("legacy history"); + crate::migration::run_migrations(&pool) + .await + .expect("upgrade migration"); + let legacy = list_task_events(&pool, community, id) + .await + .expect("read old history"); + assert_eq!(legacy.len(), 1); + assert_eq!( + legacy[0].changes, None, + "must not fabricate past before-images" + ); + update_task( + &pool, + community, + id, + &TaskPatch { + priority: Some(9), + ..TaskPatch::default() + }, + None, + ) + .await + .expect("new write after upgrade"); + let history = list_task_events(&pool, community, id) + .await + .expect("read upgraded history"); + assert_eq!( + history[1].changes, + Some(json!({"priority": {"from": 0, "to": 9}})) + ); + delete_test_community(&pool, community).await; +} + +/// HW-017: the revision counter must advance on real change and hold still +/// on a semantic no-op. If a restated value bumped the revision, every +/// other client's `expected_revision` would be invalidated by a write that +/// changed nothing, manufacturing spurious 409s on idempotent retries. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn revision_advances_on_real_change_and_holds_on_a_semantic_noop() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let creator = make_test_user(&pool, community, 0x51).await; + + let task = create_task( + &pool, + community, + NewTask { + created_by_pubkey: Some(creator.clone()), + title: "revision probe".to_owned(), + ..NewTask::default() + }, + ) + .await + .expect("create task"); + assert_eq!(task.revision, 0, "a fresh task starts at revision 0"); + + let bumped = update_task( + &pool, + community, + task.id, + &TaskPatch { + status: Some(TaskStatus::InProgress), + ..TaskPatch::default() + }, + Some(&creator), + ) + .await + .expect("real change"); + assert_eq!(bumped.revision, 1, "a real change bumps exactly once"); + + // The API returns before writing when the requested fields already match; + // this checks that fast path, separately from direct SQL below. + let restated = update_task( + &pool, + community, + task.id, + &TaskPatch { + status: Some(TaskStatus::InProgress), + title: Some("revision probe".to_owned()), + ..TaskPatch::default() + }, + Some(&creator), + ) + .await + .expect("semantic no-op"); + assert_eq!( + restated.revision, 1, + "a semantic no-op must NOT bump the revision" + ); + assert_eq!( + restated.updated_at, bumped.updated_at, + "a semantic no-op must not touch updated_at either" + ); + + let bumped_again = update_task( + &pool, + community, + task.id, + &TaskPatch { + title: Some("renamed".to_owned()), + ..TaskPatch::default() + }, + Some(&creator), + ) + .await + .expect("second real change"); + assert_eq!( + bumped_again.revision, 2, + "the counter still advances after a no-op" + ); + + // Body is not patchable through update_task. The database trigger must + // still cover this writer and normalize attempts to set derived fields. + let changed = + sqlx::query("UPDATE tasks SET body = 'direct writer' WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(task.id) + .execute(&pool) + .await + .expect("direct payload update"); + assert_eq!(changed.rows_affected(), 1); + let direct = get_task(&pool, community, task.id) + .await + .expect("direct change"); + assert_eq!(direct.body.as_deref(), Some("direct writer")); + assert_eq!(direct.revision, 3); + assert_ne!(direct.updated_at, bumped_again.updated_at); + let restated = sqlx::query("UPDATE tasks SET body = body, revision = revision + 1000, updated_at = '2000-01-01T00:00:00Z' WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()).bind(task.id).execute(&pool).await.expect("direct derived-only update"); + assert_eq!(restated.rows_affected(), 1); + assert_eq!( + get_task(&pool, community, task.id) + .await + .expect("normalized direct no-op"), + direct + ); + + delete_test_community(&pool, community).await; +} + +/// HW-017: the guard itself. A patch built from a stale snapshot must be +/// rejected with `StaleRevision` and must leave the row untouched. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn a_stale_expected_revision_is_rejected_and_changes_nothing() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let creator = make_test_user(&pool, community, 0x52).await; + + let task = create_task( + &pool, + community, + NewTask { + created_by_pubkey: Some(creator.clone()), + title: "contended".to_owned(), + ..NewTask::default() + }, + ) + .await + .expect("create task"); + + // Writer A reads revision 0 and commits, moving the row to revision 1. + let winner = update_task( + &pool, + community, + task.id, + &TaskPatch { + title: Some("writer A won".to_owned()), + expected_revision: Some(task.revision), + ..TaskPatch::default() + }, + Some(&creator), + ) + .await + .expect("writer A commits against a fresh snapshot"); + assert_eq!(winner.revision, 1); + + // Writer B still holds the revision-0 snapshot. Its write must lose. + let error = update_task( + &pool, + community, + task.id, + &TaskPatch { + title: Some("writer B clobbers".to_owned()), + expected_revision: Some(task.revision), + ..TaskPatch::default() + }, + Some(&creator), + ) + .await + .expect_err("a stale write must not silently win"); + match error { + DbError::StaleRevision { + task_id, + expected, + actual, + } => { + assert_eq!(task_id, task.id); + assert_eq!(expected, 0, "the snapshot writer B read"); + assert_eq!(actual, 1, "the revision the row actually carries"); + } + other => panic!("expected StaleRevision, got {other:?}"), + } + + // The rejection must be total: writer A's value survives intact. + let after = get_task(&pool, community, task.id).await.expect("re-fetch"); + assert_eq!( + after.title, "writer A won", + "the losing write must not have applied any field" + ); + assert_eq!(after.revision, 1, "a rejected write must not bump"); + + // Re-fetching and retrying against the current revision succeeds. + let retried = update_task( + &pool, + community, + task.id, + &TaskPatch { + title: Some("writer B retried".to_owned()), + expected_revision: Some(after.revision), + ..TaskPatch::default() + }, + Some(&creator), + ) + .await + .expect("retry against the current revision"); + assert_eq!(retried.title, "writer B retried"); + assert_eq!(retried.revision, 2); + + // A patch that omits `expected_revision` keeps the previous + // last-write-wins behaviour, so existing clients are unaffected. + let unguarded = update_task( + &pool, + community, + task.id, + &TaskPatch { + title: Some("unguarded still works".to_owned()), + ..TaskPatch::default() + }, + Some(&creator), + ) + .await + .expect("an unguarded patch is still accepted"); + assert_eq!(unguarded.revision, 3); + + delete_test_community(&pool, community).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn concurrent_guarded_writers_commit_one_revision_and_one_history_transition() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let task = create_task( + &pool, + community, + NewTask { + title: "shared task".into(), + ..NewTask::default() + }, + ) + .await + .expect("create"); + let first = TaskPatch { + priority: Some(1), + expected_revision: Some(0), + ..TaskPatch::default() + }; + let second = TaskPatch { + priority: Some(2), + expected_revision: Some(0), + ..TaskPatch::default() + }; + let (a, b) = tokio::join!( + update_task(&pool, community, task.id, &first, None), + update_task(&pool, community, task.id, &second, None) + ); + assert_eq!(usize::from(a.is_ok()) + usize::from(b.is_ok()), 1); + let failure = if a.is_err() { a } else { b }; + assert!(matches!( + failure, + Err(DbError::StaleRevision { + expected: 0, + actual: 1, + .. + }) + )); + let current = get_task(&pool, community, task.id).await.expect("current"); + assert_eq!(current.revision, 1); + let events = list_task_events(&pool, community, task.id) + .await + .expect("history"); + assert_eq!(events.len(), 2); + assert_eq!( + events[1].changes, + Some(json!({"priority": {"from": 0, "to": current.priority}})) + ); +} diff --git a/crates/buzz-pubsub/src/conn_control.rs b/crates/buzz-pubsub/src/conn_control.rs index bc177cff139..ad6335d9f4d 100644 --- a/crates/buzz-pubsub/src/conn_control.rs +++ b/crates/buzz-pubsub/src/conn_control.rs @@ -2,9 +2,9 @@ //! //! Under horizontal scaling a member's live connections may land on any pod, //! so a moderation action taken on one pod (a ban) must reach the pod holding -//! the victim's socket. This module carries connection-control intents — today -//! only "disconnect this pubkey" — to every pod, which each apply locally -//! against their own [`crate::ConnectionManager`]. +//! the victim's socket. This module carries connection-control intents to every +//! pod, which each apply locally against their own live connections. Task +//! invalidations are idempotent advisories; disconnects are imperative. //! //! This is deliberately a **separate** channel from `cache_invalidation`: a //! cache-key drop is a pure, idempotent hint (the DB is re-read on the next @@ -54,6 +54,16 @@ pub fn parse_conn_control_channel(channel: &str) -> Option { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "op")] pub enum ConnControl { + /// Tell authorized clients to refetch task state. `None` means the + /// community-wide task list changed; `Some` identifies a channel scope. + /// `origin_generation` lets the publishing relay suppress its Redis echo + /// after already delivering locally. + InvalidateTasks { + /// Channel whose tasks changed, or community-wide when absent. + channel_id: Option, + /// Per-process generation of the publishing relay. + origin_generation: Uuid, + }, /// Disconnect every live socket bound to the carrying community. DisconnectCommunity, /// Disconnect every live connection authenticated as `pubkey` in the @@ -226,4 +236,18 @@ mod tests { let json = serde_json::to_string(&cmd).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), cmd); } + + #[test] + fn task_invalidation_scopes_roundtrip_without_task_content() { + for channel_id in [None, Some(Uuid::from_u128(0x1234))] { + let cmd = ConnControl::InvalidateTasks { + channel_id, + origin_generation: Uuid::from_u128(0x5678), + }; + let json = serde_json::to_string(&cmd).unwrap(); + assert!(!json.contains("title")); + assert!(!json.contains("task_id")); + assert_eq!(serde_json::from_str::(&json).unwrap(), cmd); + } + } } diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index deb2e7e16a5..d022bcab01c 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -86,8 +86,8 @@ async-compression = { version = "0.4.42", features = ["tokio", "gzip"] } dev = ["buzz-auth/dev"] [dev-dependencies] -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } # Relay-driven mesh lifecycle smoke (examples/mesh_relay_lifecycle_smoke.rs): # the relay client for discovery notes and the exact ed25519 the mesh owner # keys use for binding verification. diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 37c549610de..1dfdd0beade 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -948,11 +948,19 @@ async fn submit_event_authed( }; } }; - if let Some(owner) = nip_oa_owner { - super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await; - } - let kind_u32 = buzz_core::kind::event_kind_u32(&event); + // Machine commands own their atomic owner/home materialization. In + // particular, a rejected observation or standalone consent must not mint an owner side effect. + if !matches!( + kind_u32, + buzz_core::kind::KIND_MACHINE_ENROLLMENT + | buzz_core::kind::KIND_MACHINE_OBSERVATION + | buzz_core::kind::KIND_MACHINE_ENROLLMENT_CONSENT + ) { + if let Some(owner) = nip_oa_owner { + super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await; + } + } let auth = IngestAuth::Http { pubkey, scopes: buzz_auth::Scope::all_known(), // Pure Nostr: full scopes, channel access via membership diff --git a/crates/buzz-relay/src/api/git/store.rs b/crates/buzz-relay/src/api/git/store.rs index bdfca8dcf2d..8bfffa09452 100644 --- a/crates/buzz-relay/src/api/git/store.rs +++ b/crates/buzz-relay/src/api/git/store.rs @@ -32,6 +32,8 @@ use s3::error::S3Error; use s3::{Bucket, Region}; use sha2::{Digest, Sha256}; +mod probe_deadline; + /// Opaque object-store ETag (used for `If-Match` on pointer CAS). #[derive(Debug, Clone, PartialEq, Eq)] pub struct ETag(pub String); @@ -101,7 +103,7 @@ pub enum StoreError { /// Configuration for `GitStore::run_conformance_probe`. /// -/// Defaults: 32-way concurrency, 3 rounds. The probe is a deployment gate — +/// Defaults: 32-way concurrency, 3 rounds, 120 seconds total. The probe is a deployment gate — /// run at startup, fail-closed. See `docs/git-on-object-storage.md` §Conformance. #[derive(Debug, Clone)] pub struct ProbeConfig { @@ -109,6 +111,8 @@ pub struct ProbeConfig { pub race_width: usize, /// How many rounds to run each race phase. pub race_rounds: usize, + /// Deadline for the entire probe, including every race and cleanup request. + pub total_timeout: std::time::Duration, } impl Default for ProbeConfig { @@ -116,6 +120,7 @@ impl Default for ProbeConfig { Self { race_width: 32, race_rounds: 3, + total_timeout: std::time::Duration::from_secs(120), } } } @@ -149,7 +154,7 @@ pub struct ProbeReport { #[derive(Debug, thiserror::Error)] #[error("conformance probe failed in phase '{phase}' (round {round}, key {key}): {reason}")] pub struct ProbeFailure { - /// One of `sequential`, `if_match_race`, `if_none_match_race`, `etag_consistency`. + /// One of `config`, `deadline`, `sequential`, `if_match_race`, `if_none_match_race`, `etag_consistency`. pub phase: &'static str, /// Round index (0-based) when this phase ran multiple rounds. pub round: usize, @@ -573,7 +578,10 @@ impl GitStore { /// 4. **`etag_consistency`** — round-trip an ETag from `get_pointer` into /// `put_pointer(IfMatch(...))` and assert `Won`. Tests that the token /// is opaque and stable between read and CAS. - pub async fn run_conformance_probe(&self, cfg: ProbeConfig) -> Result { + async fn run_conformance_probe_inner( + &self, + cfg: ProbeConfig, + ) -> Result { use std::sync::Arc; if cfg.race_width < 2 || cfg.race_rounds == 0 { return Err(ProbeFailure { @@ -1183,6 +1191,7 @@ mod probe { .run_conformance_probe(ProbeConfig { race_width: 8, race_rounds: 2, + ..ProbeConfig::default() }) .await .expect("conformance probe"); diff --git a/crates/buzz-relay/src/api/git/store/probe_deadline.rs b/crates/buzz-relay/src/api/git/store/probe_deadline.rs new file mode 100644 index 00000000000..900dffe7916 --- /dev/null +++ b/crates/buzz-relay/src/api/git/store/probe_deadline.rs @@ -0,0 +1,37 @@ +//! A total deadline around every request in the startup admission probe. + +use super::{GitStore, ProbeConfig, ProbeFailure, ProbeReport, StoreError}; + +impl GitStore { + /// Admit the backend only when all conformance phases finish within the budget. + /// + /// Dropping the inner future cancels the pending request futures, including + /// the non-spawned racers in `join_all`. An unfinished racer is never treated + /// as an observed transport drop or a successful admission. + pub async fn run_conformance_probe(&self, cfg: ProbeConfig) -> Result { + let timeout = cfg.total_timeout; + if timeout.is_zero() { + return Err(ProbeFailure { + phase: "config", + round: 0, + key: String::new(), + reason: "total_timeout must be greater than zero".into(), + } + .into()); + } + tokio::time::timeout(timeout, self.run_conformance_probe_inner(cfg)) + .await + .map_err(|_| ProbeFailure { + phase: "deadline", + round: 0, + key: String::new(), + reason: format!( + "total probe deadline exceeded after {} ms; backend not admitted", + timeout.as_millis() + ), + })? + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-relay/src/api/git/store/probe_deadline/tests.rs b/crates/buzz-relay/src/api/git/store/probe_deadline/tests.rs new file mode 100644 index 00000000000..a0c07edf6ae --- /dev/null +++ b/crates/buzz-relay/src/api/git/store/probe_deadline/tests.rs @@ -0,0 +1,161 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::body::{Body, Bytes}; +use axum::extract::State; +use axum::http::{header, HeaderMap, Method, Response, StatusCode, Uri}; +use axum::routing::any; +use axum::Router; +use tokio::sync::Mutex; + +use super::super::{GitStore, ProbeConfig, StoreError}; + +#[derive(Default)] +struct Backend { + objects: Mutex>, + requests: AtomicUsize, + cas_requests: AtomicUsize, + stall_first_cas: bool, +} + +async fn object( + State(backend): State>, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Bytes, +) -> Response { + backend.requests.fetch_add(1, Ordering::SeqCst); + if method == Method::PUT && headers.contains_key(header::IF_MATCH) { + let index = backend.cas_requests.fetch_add(1, Ordering::SeqCst); + if backend.stall_first_cas && index == 0 { + std::future::pending::<()>().await; + } + } + let mut objects = backend.objects.lock().await; + let key = uri.path().to_string(); + let reply = |status, body, tag: Option<&str>| { + let mut response = Response::builder().status(status); + if let Some(tag) = tag { + response = response.header(header::ETAG, tag); + } + response.body(Body::from(body)).expect("fixture response") + }; + match method { + Method::PUT => { + let existing = objects.get(&key); + if (headers.contains_key(header::IF_NONE_MATCH) && existing.is_some()) + || headers.get(header::IF_MATCH).is_some_and(|condition| { + existing.is_none_or(|(_, tag)| condition.as_bytes() != tag.as_bytes()) + }) + { + return reply(StatusCode::PRECONDITION_FAILED, Bytes::new(), None); + } + let tag = format!("\"{}\"", GitStore::digest_hex(&body)); + objects.insert(key, (body, tag.clone())); + reply(StatusCode::OK, Bytes::new(), Some(&tag)) + } + Method::GET => match objects.get(&key) { + Some((body, tag)) => reply(StatusCode::OK, body.clone(), Some(tag)), + None => reply(StatusCode::NOT_FOUND, Bytes::new(), None), + }, + Method::DELETE => { + objects.remove(&key); + reply(StatusCode::NO_CONTENT, Bytes::new(), None) + } + _ => reply(StatusCode::METHOD_NOT_ALLOWED, Bytes::new(), None), + } +} + +async fn start_backend(stall: bool) -> (GitStore, Arc, tokio::task::JoinHandle<()>) { + let state = Arc::new(Backend { + stall_first_cas: stall, + ..Default::default() + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("local fixture listener"); + let endpoint = format!("http://{}", listener.local_addr().expect("fixture address")); + let app = Router::new() + .route("/{*key}", any(object)) + .with_state(Arc::clone(&state)); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.expect("fixture server"); + }); + let store = GitStore::new( + &endpoint, + "fixture-access", + "fixture-secret", + "probe-test", + "us-east-1", + buzz_media::config::S3AddressingStyle::Path, + ) + .expect("fixture store"); + (store, state, server) +} + +#[tokio::test] +async fn stalled_racer_fails_the_total_deadline_instead_of_admitting_backend() { + let (store, state, server) = start_backend(true).await; + let started = Instant::now(); + let result = tokio::time::timeout( + Duration::from_secs(3), + store.run_conformance_probe(ProbeConfig { + race_width: 3, + race_rounds: 1, + total_timeout: Duration::from_millis(250), + }), + ) + .await; + server.abort(); + let _ = server.await; + let Err(StoreError::Probe(failure)) = result.expect("production deadline must finish first") + else { + panic!("a pending racer must never become successful admission"); + }; + assert!(started.elapsed() < Duration::from_secs(2)); + assert_eq!(state.cas_requests.load(Ordering::SeqCst), 3); + assert_eq!(failure.phase, "deadline"); + assert!(failure.reason.contains("backend not admitted")); + eprintln!("STALLED_S3_PROBE_DENIED: {failure}"); +} + +#[tokio::test] +async fn responsive_backend_still_completes_all_conformance_phases() { + let (store, state, server) = start_backend(false).await; + let result = store + .run_conformance_probe(ProbeConfig { + race_width: 4, + race_rounds: 2, + // rust-s3 retries classified 412 responses once after a one-second + // backoff. Four race phases therefore need more than four seconds. + total_timeout: Duration::from_secs(10), + }) + .await; + server.abort(); + let _ = server.await; + let report = result.expect("responsive conditional-write backend"); + assert_eq!(report.race_width, 4); + assert_eq!(report.race_rounds, 2); + assert_eq!(report.transport_drops, 0); + // Two four-writer races, six 412 retries, and two ETag consistency writes. + assert_eq!(state.cas_requests.load(Ordering::SeqCst), 16); + eprintln!("RESPONSIVE_S3_PROBE_ADMITTED: {report:?}"); +} + +#[tokio::test] +async fn zero_deadline_is_invalid_without_sending_backend_requests() { + let (store, state, server) = start_backend(false).await; + let result = store + .run_conformance_probe(ProbeConfig { + total_timeout: Duration::ZERO, + ..ProbeConfig::default() + }) + .await; + server.abort(); + let _ = server.await; + assert_eq!(state.requests.load(Ordering::SeqCst), 0); + assert!(matches!(result, Err(StoreError::Probe(failure)) if failure.phase == "config")); +} diff --git a/crates/buzz-relay/src/api/machines.rs b/crates/buzz-relay/src/api/machines.rs new file mode 100644 index 00000000000..0b2ddd46976 --- /dev/null +++ b/crates/buzz-relay/src/api/machines.rs @@ -0,0 +1,149 @@ +//! Strict signed owner-only machine reads. No directory, admin or dev-auth fallback. + +use crate::{ + api::{api_error, bridge, internal_error}, + state::AppState, +}; +use axum::{ + extract::{Path, Query, RawQuery, State}, + http::{header, HeaderMap, HeaderValue, StatusCode}, + response::Json, +}; +use buzz_core::TenantContext; +use nostr::PublicKey; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::sync::Arc; +use uuid::Uuid; + +type Failure = (StatusCode, Json); + +/// Owner-scoped ascending machine UUID pagination. +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MachineQuery { + after: Option, + limit: Option, +} + +async fn authorize( + state: &Arc, + headers: &HeaderMap, + path: &str, + query: Option<&str>, +) -> Result<(TenantContext, PublicKey), Failure> { + if headers.get_all(header::AUTHORIZATION).iter().count() != 1 { + return Err(api_error( + StatusCode::UNAUTHORIZED, + "one signed Authorization header required", + )); + } + let mut hosts = headers.get_all(header::HOST).iter(); + let (Some(host), None) = (hosts.next(), hosts.next()) else { + return Err(api_error( + StatusCode::BAD_REQUEST, + "one Host header required", + )); + }; + let host = host + .to_str() + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid Host"))?; + let tenant = crate::tenant::bind_community(&state.db, host) + .await + .map_err(|_| api_error(StatusCode::NOT_FOUND, "community unavailable"))?; + let path = query.map_or_else(|| path.to_owned(), |query| format!("{path}?{query}")); + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path); + // `true` is intentional even when the relay otherwise allows dev X-Pubkey. + let auth = bridge::verify_bridge_auth_with_options(headers, "GET", &url, None, true, false)?; + bridge::enforce_http_admission(state, &tenant, &auth.pubkey).await?; + bridge::check_nip98_replay(state, &tenant, auth.event_id_bytes).await?; + super::relay_members::enforce_relay_membership( + state, + tenant.community(), + &auth.pubkey.to_bytes(), + super::relay_members::extract_auth_tag_header(headers), + auth.signed_created_at, + ) + .await?; + let restrictions = state + .db + .moderation_restriction_state(tenant.community(), &auth.pubkey.to_bytes()) + .await + .map_err(|_| internal_error("machine read restriction lookup failed"))?; + if restrictions.banned { + return Err(api_error( + StatusCode::FORBIDDEN, + "community access unavailable", + )); + } + Ok((tenant, auth.pubkey)) +} + +fn private_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + headers +} + +/// GET /api/machines: ownership is applied in SQL before pagination. +pub async fn list_machines( + State(state): State>, + RawQuery(raw): RawQuery, + Query(query): Query, + headers: HeaderMap, +) -> Result<(HeaderMap, Json), Failure> { + let (tenant, owner) = authorize(&state, &headers, "/api/machines", raw.as_deref()).await?; + let limit = query.limit.unwrap_or(50); + if !(1..=100).contains(&limit) { + return Err(api_error( + StatusCode::BAD_REQUEST, + "limit must be between 1 and 100", + )); + } + let mut machines = state + .db + .list_machines( + tenant.community(), + &owner.to_bytes(), + query.after, + limit + 1, + ) + .await + .map_err(|_| internal_error("machine list failed"))?; + let next = if machines.len() > limit as usize { + machines.truncate(limit as usize); + machines + .last() + .and_then(|machine| machine.get("machine_id")) + .cloned() + } else { + None + }; + Ok(( + private_headers(), + Json(json!({"machines": machines, "next_cursor": next})), + )) +} + +/// GET /api/machines/{id}: other owners and tenants receive the same missing result. +pub async fn get_machine( + State(state): State>, + Path(id): Path, + RawQuery(raw): RawQuery, + headers: HeaderMap, +) -> Result<(HeaderMap, Json), Failure> { + let (tenant, owner) = authorize( + &state, + &headers, + &format!("/api/machines/{id}"), + raw.as_deref(), + ) + .await?; + let machine = state + .db + .get_machine(tenant.community(), &owner.to_bytes(), id) + .await + .map_err(|_| internal_error("machine detail failed"))? + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "machine not found"))?; + Ok((private_headers(), Json(machine))) +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 05682a51f9f..d8f4ee23131 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -6,6 +6,7 @@ pub mod events; pub mod gifs; pub mod git; pub mod invites; +pub mod machines; pub mod media; pub mod mesh_demo; pub mod nip05; @@ -182,6 +183,41 @@ pub mod relay_members { } } + /// Enforce a per-machine capability grant for an agent. **Default deny.** + /// + /// Unlike [`enforce_relay_membership`], this is NOT relaxed by + /// `require_relay_membership`: an open relay still denies ungranted + /// cross-machine actions. Absence of a grant is a denial. + pub async fn require_capability( + state: &AppState, + community: CommunityId, + agent_pubkey: &[u8], + capability: &str, + target: &str, + ) -> Result<(), (StatusCode, Json)> { + match state + .db + .capability_is_granted(community, agent_pubkey, capability, target) + .await + { + Ok(true) => Ok(()), + Ok(false) => Err(( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "capability_not_granted", + "capability": capability, + "target": target, + "message": format!( + "agent has no active {capability} grant for target {target}" + ), + })), + )), + Err(e) => Err(super::internal_error(&format!( + "capability check failed: {e}" + ))), + } + } + /// Extract NIP-OA owner from an auth tag without membership enforcement. /// /// Used on open relays (`require_relay_membership = false`) to opportunistically @@ -384,3 +420,6 @@ pub mod relay_members { } } } + +#[cfg(test)] +mod workflow_approval_postgres_tests; diff --git a/crates/buzz-relay/src/api/tasks.rs b/crates/buzz-relay/src/api/tasks.rs index 030a3b91ca8..c52b0978b45 100644 --- a/crates/buzz-relay/src/api/tasks.rs +++ b/crates/buzz-relay/src/api/tasks.rs @@ -21,6 +21,7 @@ use axum::{ http::{HeaderMap, StatusCode}, response::Json, }; +use base64::Engine as _; use chrono::{DateTime, Utc}; use serde::Deserialize; use serde_json::Value; @@ -28,13 +29,16 @@ use uuid::Uuid; use buzz_core::task::{TaskAction, TaskStatus}; use buzz_core::TenantContext; -use buzz_db::task::{NewTask, TaskEventRecord, TaskFilter, TaskPatch, TaskRecord}; +use buzz_db::task::{NewTask, TaskCursor, TaskEventRecord, TaskFilter, TaskPatch, TaskRecord}; use crate::{ api::{api_error, bridge, internal_error}, state::AppState, }; +mod admission; +pub use admission::get_fleet_admission; + const DEFAULT_TASK_LIMIT: i64 = 50; const MAX_TASK_LIMIT: i64 = 200; const MAX_TITLE_CHARS: usize = 200; @@ -48,6 +52,7 @@ pub struct TasksQuery { source_ref: Option, include_archived: Option, limit: Option, + before: Option, } /// Body of `POST /api/tasks`. @@ -72,6 +77,7 @@ pub struct CreateTaskRequest { /// `skip_serializing_if`/`default`) is what distinguishes the two. #[derive(Debug, Deserialize, Default)] pub struct UpdateTaskRequest { + expected_revision: Option, status: Option, title: Option, priority: Option, @@ -153,6 +159,10 @@ fn validate_title(title: &str) -> Result)> { /// not a server fault. fn map_task_error(context: &str, error: buzz_db::DbError) -> (StatusCode, Json) { match &error { + buzz_db::DbError::StaleRevision { task_id, expected, actual } => api_error( + StatusCode::CONFLICT, + &format!("task {task_id} was modified (expected revision {expected}, actual {actual}); re-fetch and retry"), + ), buzz_db::DbError::NotFound(_) => api_error(StatusCode::NOT_FOUND, "task not found"), buzz_db::DbError::InvalidData(message) => api_error(StatusCode::BAD_REQUEST, message), buzz_db::DbError::AccessDenied(_) => api_error( @@ -312,9 +322,35 @@ pub async fn create_task( .await .map_err(|error| map_task_error("create task", error))?; + state + .invalidate_tasks(tenant.community(), task.channel_id) + .await; + Ok(Json(task_json(&task))) } +// Cursor timestamps retain subsecond precision; the task wire format intentionally +// keeps its existing seconds representation for old clients. +fn decode_task_cursor(raw: &str) -> Result)> { + let invalid = || api_error(StatusCode::BAD_REQUEST, "invalid task cursor"); + if raw.len() > 256 { + return Err(invalid()); + } + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(raw) + .map_err(|_| invalid())?; + serde_json::from_slice(&bytes).map_err(|_| invalid()) +} + +fn encode_task_cursor(task: &TaskRecord) -> Result)> { + let bytes = serde_json::to_vec(&TaskCursor { + updated_at: task.updated_at, + id: task.id, + }) + .map_err(|error| internal_error(&format!("encode task cursor: {error}")))?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) +} + /// `GET /api/tasks` — list this community's tasks, newest-modified first. pub async fn list_tasks( State(state): State>, @@ -329,6 +365,11 @@ pub async fn list_tasks( "limit must be between 1 and 200", )); } + let before = query + .before + .as_deref() + .map(decode_task_cursor) + .transpose()?; let status = query.status.as_deref().map(parse_status).transpose()?; let assignee = query .assignee @@ -350,7 +391,13 @@ pub async fn list_tasks( enforce_channel_access(&state, &tenant, &pubkey, Some(channel_id)).await?; } - let tasks = state + // The visibility predicate must be part of the database query: filtering + // an already-limited page can hide all accessible work behind private tasks. + let accessible = state + .get_accessible_channel_ids_cached(tenant.community(), &pubkey.to_bytes()) + .await + .map_err(|error| internal_error(&format!("task channel access lookup: {error}")))?; + let mut tasks = state .db .list_tasks( tenant.community(), @@ -360,28 +407,25 @@ pub async fn list_tasks( channel_id: query.channel, source_ref: query.source_ref.clone(), include_archived: query.include_archived.unwrap_or(false), - limit, + visible_channel_ids: Some(accessible.into_iter().collect()), + before, + limit: limit + 1, }, ) .await .map_err(|error| map_task_error("list tasks", error))?; - // Channel-bound tasks the caller cannot see are filtered out rather than - // failing the whole page: a list is a view of what you may see. - let accessible = state - .get_accessible_channel_ids_cached(tenant.community(), &pubkey.to_bytes()) - .await - .map_err(|error| internal_error(&format!("task channel access lookup: {error}")))?; - let visible: Vec = tasks - .iter() - .filter(|task| { - task.channel_id - .is_none_or(|channel_id| accessible.contains(&channel_id)) - }) - .map(task_json) - .collect(); - - Ok(Json(serde_json::json!({ "tasks": visible }))) + let has_more = tasks.len() > limit as usize; + tasks.truncate(limit as usize); + let next_cursor = if has_more { + tasks.last().map(encode_task_cursor).transpose()? + } else { + None + }; + let visible: Vec = tasks.iter().map(task_json).collect(); + Ok(Json( + serde_json::json!({ "tasks": visible, "next_cursor": next_cursor }), + )) } /// `GET /api/tasks/{id}` — one task plus its full event history. @@ -407,9 +451,15 @@ pub async fn get_task( .await .map_err(|error| map_task_error("list task events", error))?; + let attempts = state + .db + .list_task_attempts(tenant.community(), task_id) + .await + .map_err(|error| map_task_error("list task attempts", error))?; Ok(Json(serde_json::json!({ "task": task_json(&task), "events": events.iter().map(task_event_json).collect::>(), + "attempts": attempts, }))) } @@ -428,6 +478,7 @@ pub async fn update_task( .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid task JSON: {e}")))?; let patch = TaskPatch { + expected_revision: request.expected_revision, status: request.status.as_deref().map(parse_status).transpose()?, title: request.title.as_deref().map(validate_title).transpose()?, priority: request.priority, @@ -464,6 +515,14 @@ pub async fn update_task( .await .map_err(|error| map_task_error("update task", error))?; + // The DB commit precedes notification. Rejected and semantic no-op writes + // do not announce a new revision; clients obtain task data via authorized GET. + if task.revision != existing.revision { + state + .invalidate_tasks(tenant.community(), task.channel_id) + .await; + } + Ok(Json(task_json(&task))) } @@ -529,6 +588,10 @@ pub async fn append_task_event( .await .map_err(|error| map_task_error("append task event", error))?; + state + .invalidate_tasks(tenant.community(), task.channel_id) + .await; + Ok(Json(task_event_json(&event))) } @@ -550,6 +613,7 @@ fn task_json(task: &TaskRecord) -> Value { "archived_at": task.archived_at.map(|value| value.timestamp()), "created_at": task.created_at.timestamp(), "updated_at": task.updated_at.timestamp(), + "revision": task.revision, }) } @@ -562,552 +626,11 @@ fn task_event_json(event: &TaskEventRecord) -> Value { "from_status": event.from_status.map(|status| status.as_str()), "to_status": event.to_status.map(|status| status.as_str()), "body": event.body, + "changes": event.changes, "created_at": event.created_at.timestamp(), }) } #[cfg(test)] -mod tests { - use super::*; - use axum::http::Uri; - - #[test] - fn request_path_preserves_signed_query_verbatim() { - assert_eq!( - request_path("/api/tasks", Some("status=todo&limit=10")), - "/api/tasks?status=todo&limit=10" - ); - assert_eq!(request_path("/api/tasks", None), "/api/tasks"); - assert_eq!(request_path("/api/tasks", Some("")), "/api/tasks"); - } - - #[test] - fn source_ref_survives_verbatim_into_the_signed_path() { - // The client signs the raw query, so the relay must reconstruct it - // byte-for-byte. A normalised or re-ordered `source_ref` would break - // the NIP-98 signature rather than merely filter differently. - let raw = "channel=6f1b0e2c-0000-4000-8000-000000000001&source_ref=abc123"; - assert_eq!( - request_path("/api/tasks", Some(raw)), - format!("/api/tasks?{raw}") - ); - } - - #[test] - fn source_ref_is_parsed_as_an_opaque_optional_string() { - // Opaque TEXT by design (migrations/0046_task_system.sql): the relay - // must not validate it as an event id, and its absence must stay - // distinct from a present value. - fn parse(query: &str) -> TasksQuery { - let uri: Uri = format!("http://relay.invalid/api/tasks?{query}") - .parse() - .expect("valid uri"); - Query::::try_from_uri(&uri).expect("parses").0 - } - - assert_eq!(parse("status=todo").source_ref, None); - assert_eq!( - parse("source_ref=not-an-event-id").source_ref.as_deref(), - Some("not-an-event-id") - ); - } - - #[test] - fn title_length_is_counted_in_characters_not_bytes() { - // 200 multi-byte characters is 600 bytes but a legal title; counting - // bytes here would 400 a request the database would have accepted. - let multibyte = "é".repeat(200); - assert_eq!( - validate_title(&multibyte).expect("200 chars is legal"), - multibyte - ); - assert!(validate_title(&"é".repeat(201)).is_err()); - } - - #[test] - fn title_is_trimmed_and_must_not_be_blank() { - assert_eq!(validate_title(" ship it ").expect("trims"), "ship it"); - assert!(validate_title(" ").is_err()); - assert!(validate_title("").is_err()); - } - - #[test] - fn assignee_must_be_a_32_byte_hex_pubkey() { - let valid = "ab".repeat(32); - assert_eq!( - parse_pubkey("assignee", &valid).expect("valid"), - vec![0xab; 32] - ); - assert!(parse_pubkey("assignee", "not-hex").is_err()); - assert!(parse_pubkey("assignee", &"ab".repeat(31)).is_err()); - assert!(parse_pubkey("assignee", &"ab".repeat(33)).is_err()); - } - - #[test] - fn absent_and_null_assignee_are_different_patches() { - // The whole point of the double option: `{}` leaves the assignee - // alone, `{"assignee": null}` unassigns. - let absent: UpdateTaskRequest = serde_json::from_str("{}").expect("absent"); - assert_eq!(absent.assignee, None); - - let cleared: UpdateTaskRequest = - serde_json::from_str(r#"{"assignee": null}"#).expect("null"); - assert_eq!(cleared.assignee, Some(None)); - - let set: UpdateTaskRequest = serde_json::from_str(r#"{"assignee": "abc"}"#).expect("set"); - assert_eq!(set.assignee, Some(Some("abc".to_owned()))); - } - - #[test] - fn absent_and_null_due_at_are_different_patches() { - let absent: UpdateTaskRequest = serde_json::from_str("{}").expect("absent"); - assert_eq!(absent.due_at, None); - - let cleared: UpdateTaskRequest = serde_json::from_str(r#"{"due_at": null}"#).expect("null"); - assert_eq!(cleared.due_at, Some(None)); - } - - #[test] - fn task_wire_renders_status_and_hex_pubkeys() { - let task = TaskRecord { - id: Uuid::nil(), - channel_id: None, - created_by_pubkey: Some(vec![0xab; 32]), - assignee_pubkey: None, - parent_task_id: None, - title: "ship it".to_owned(), - body: None, - status: TaskStatus::InProgress, - priority: 3, - source: Some("claude".to_owned()), - source_ref: None, - due_at: None, - done_at: None, - archived_at: None, - created_at: Utc::now(), - updated_at: Utc::now(), - }; - let wire = task_json(&task); - assert_eq!(wire["status"], "in_progress"); - assert_eq!(wire["created_by"], hex::encode([0xab; 32])); - assert!(wire["assignee"].is_null()); - assert_eq!(wire["priority"], 3); - // Raw bytes must never reach the wire. - assert!(wire.get("created_by_pubkey").is_none()); - } - - #[test] - fn task_event_wire_renders_both_status_ends() { - let event = TaskEventRecord { - id: 7, - task_id: Uuid::nil(), - actor_pubkey: None, - action: TaskAction::StatusChanged, - from_status: Some(TaskStatus::Todo), - to_status: Some(TaskStatus::Done), - body: None, - created_at: Utc::now(), - }; - let wire = task_event_json(&event); - assert_eq!(wire["action"], "status_changed"); - assert_eq!(wire["from_status"], "todo"); - assert_eq!(wire["to_status"], "done"); - } - - /// Route-level private-channel authorization (COMPAT LANE 3, §7 closure). - /// - /// Drives the REAL router (`build_router` + `oneshot`) with REAL NIP-98 - /// auth headers against a REAL Postgres community containing a private - /// channel and a channel-bound task. Proves at the route seam — not the - /// db seam — that a relay member who is NOT a channel member: - /// * gets 404 (never 403, never the task) on GET/PATCH/POST-events, - /// * gets the task silently filtered out of a channel list, and - /// * cannot even create a task bound to the private channel. - /// - /// The relay-membership gate is exercised with `require_relay_membership - /// = true` so the 404s below are authz verdicts, not gate bypasses. - /// - /// Postgres + Redis are required: run with - /// `cargo test -p buzz-relay --lib api::tasks -- --ignored`. - mod route_authz { - use super::super::*; - use crate::state::AppState; - use buzz_core::channel::{ChannelType, ChannelVisibility}; - use buzz_db::task::NewTask; - use nostr::Keys; - use sha2::{Digest, Sha256}; - - use axum::body::{to_bytes, Body}; - use axum::http::{header, Request, StatusCode}; - use tower::ServiceExt; - - const TEST_DB_URL: &str = "postgres://buzz:***@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - - /// Same trick as the invites tests: the shared AlwaysFreshReplayGuard - /// is gated behind buzz-auth/test-utils, which this crate doesn't - /// enable, so define the pass-through locally. - struct AlwaysFreshReplayGuard; - - impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard { - fn try_mark_in_scope<'a>( - &'a self, - _scope: &'a str, - _event_id: &'a nostr::EventId, - _ttl_secs: u64, - ) -> std::pin::Pin< - Box< - dyn std::future::Future> - + Send - + 'a, - >, - > { - Box::pin(async { Ok(true) }) - } - } - - /// Clone of the invites.rs NIP-98 helper: signs kind:27235 over the - /// exact URL the relay will reconstruct (scheme from config.relay_url, - /// host from the tenant, path + raw query). - fn nip98_auth_header(keys: &Keys, method: &str, url: &str, body: &[u8]) -> String { - let hash: [u8; 32] = Sha256::digest(body).into(); - let tags = vec![ - nostr::Tag::parse(["u", url]).expect("u tag"), - nostr::Tag::parse(["method", method]).expect("method tag"), - nostr::Tag::parse(["payload", hex::encode(hash).as_str()]).expect("payload tag"), - ]; - let event = nostr::EventBuilder::new(nostr::Kind::HttpAuth, "") - .tags(tags) - .sign_with_keys(keys) - .expect("sign NIP-98 event"); - let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); - let encoded = - base64::Engine::encode(&base64::engine::general_purpose::STANDARD, event_json); - format!("Nostr {encoded}") - } - - #[allow(dead_code)] // AGENT-HOMES-001: shared fixture; fields used by sibling test mods - pub(super) struct Fixture { - state: Arc, - #[allow(dead_code)] - pool: sqlx::PgPool, - host: String, - community: buzz_core::CommunityId, - private_channel_id: Uuid, - task_id: Uuid, - owner: Keys, - outsider: Keys, - } - - /// Boot an AppState bound to a fresh community whose Postgres + Redis - /// are live. Redis must be real: the HTTP admission gate fails closed - /// (503) when the shared limiter is unavailable, which would mask the - /// authorization verdict under test. - pub(super) async fn fixture() -> Option { - let host = format!("task-authz-{}.example", Uuid::new_v4().simple()); - let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") - .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| TEST_DB_URL.to_string()); - let redis_url = std::env::var("BUZZ_TEST_REDIS_URL") - .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); - - let mut config = crate::config::Config::from_env().ok()?; - config.database_url = database_url.clone(); - config.redis_url = redis_url.clone(); - config.relay_url = format!("wss://{host}"); - config.require_relay_membership = true; - config.require_auth_token = false; - - let pool = sqlx::PgPool::connect(&database_url).await.ok()?; - let db = buzz_db::Db::from_pool(pool.clone()); - let ensured = db.ensure_configured_community(&host).await.ok()?; - - // Live Redis pool for admission + pubsub, mirroring invite tests. - let redis_pool = deadpool_redis::Config::from_url(&redis_url) - .create_pool(Some(deadpool_redis::Runtime::Tokio1)) - .ok()?; - let pubsub = Arc::new( - buzz_pubsub::PubSubManager::new(&redis_url, redis_pool.clone()) - .await - .ok()?, - ); - - let owner = Keys::generate(); - let outsider = Keys::generate(); - let owner_pk = owner.public_key().to_bytes().to_vec(); - let outsider_pk = outsider.public_key().to_bytes().to_vec(); - - // Both are relay members (the outer gate) so every response below - // isolates the CHANNEL gate, not relay membership. - buzz_db::user::ensure_user(&pool, ensured.id, &owner_pk) - .await - .ok()?; - buzz_db::user::ensure_user(&pool, ensured.id, &outsider_pk) - .await - .ok()?; - db.add_relay_member(ensured.id, &owner.public_key().to_hex(), "member", None) - .await - .ok()?; - db.add_relay_member(ensured.id, &outsider.public_key().to_hex(), "member", None) - .await - .ok()?; - - // Private channel owned by `owner` — outsider is not a member. - let channel = buzz_db::channel::create_channel( - &pool, - ensured.id, - "task-authz-private", - ChannelType::Stream, - ChannelVisibility::Private, - None, - &owner_pk, - None, - ) - .await - .ok()?; - - // A task bound to that private channel. - let task = db - .create_task( - ensured.id, - NewTask { - channel_id: Some(channel.id), - created_by_pubkey: Some(owner_pk.clone()), - title: "route authz probe".to_owned(), - ..NewTask::default() - }, - ) - .await - .ok()?; - - let audit = buzz_audit::AuditService::new(pool.clone()); - let auth = buzz_auth::AuthService::new(config.auth.clone()); - let search = buzz_search::SearchService::new(pool.clone()); - let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( - db.clone(), - buzz_workflow::WorkflowConfig::default(), - )); - let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; - let (mut state, _audit_shutdown) = AppState::new( - config, - db, - redis_pool, - audit, - pubsub, - auth, - search, - workflow_engine, - Keys::generate(), - media_storage, - ); - state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); - let state = Arc::new(state); - - Some(Fixture { - state, - pool, - host, - community: ensured.id, - private_channel_id: channel.id, - task_id: task.id, - owner, - outsider, - }) - } - - #[allow(dead_code)] // AGENT-HOMES-001: retained for future integration tests - async fn cleanup(f: &Fixture) { - for table in ["task_events", "tasks", "channel_members", "channels"] { - let sql = format!("DELETE FROM {table} WHERE community_id = $1"); - sqlx::query(sqlx::AssertSqlSafe(sql)) - .bind(f.community.as_uuid()) - .execute(&f.pool) - .await - .expect("cleanup channel/task rows"); - } - let _ = f - .state - .db - .remove_relay_member(f.community, &f.outsider.public_key().to_hex()) - .await; - let _ = f - .state - .db - .remove_relay_member(f.community, &f.owner.public_key().to_hex()) - .await; - sqlx::query("DELETE FROM users WHERE community_id = $1") - .bind(f.community.as_uuid()) - .execute(&f.pool) - .await - .expect("cleanup users"); - sqlx::query("DELETE FROM communities WHERE id = $1") - .bind(f.community.as_uuid()) - .execute(&f.pool) - .await - .expect("cleanup community"); - } - - impl Fixture { - async fn request( - &self, - method: &str, - path_and_query: &str, - keys: &Keys, - body: Option<&str>, - ) -> (StatusCode, serde_json::Value) { - let url = format!("https://{}{}", self.host, path_and_query); - let body_bytes = body.map(str::as_bytes).unwrap_or_default(); - let auth = nip98_auth_header(keys, method, &url, body_bytes); - let mut builder = Request::builder() - .method(method) - .uri(path_and_query) - .header(header::HOST, &self.host) - .header(header::AUTHORIZATION, auth); - if body.is_some() { - builder = builder.header(header::CONTENT_TYPE, "application/json"); - } - let response = crate::router::build_router(self.state.clone()) - .oneshot( - builder - .body(Body::from(body_bytes.to_vec())) - .expect("request"), - ) - .await - .expect("response"); - let status = response.status(); - let bytes = to_bytes(response.into_body(), 1024 * 1024) - .await - .expect("read body"); - let json: serde_json::Value = if bytes.is_empty() { - serde_json::Value::Null - } else { - serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null) - }; - (status, json) - } - } - - pub(super) async fn private_channel_assertions(f: &Fixture) { - let task_path = format!("/api/tasks/{}", f.task_id); - - // --- Positive control: the owner sees the task. Without this, 404s - // for the outsider could be any breakage at all. - let (status, body) = f.request("GET", &task_path, &f.owner, None).await; - assert_eq!( - status, - StatusCode::OK, - "owner must see the task; got {status} {body}" - ); - assert_eq!(body["task"]["title"], "route authz probe"); - - // --- GET detail as outsider: 404, never 403, never the task. - let (status, body) = f.request("GET", &task_path, &f.outsider, None).await; - assert_eq!(status, StatusCode::NOT_FOUND, "got {status} {body}"); - assert_eq!(body["error"], "task not found"); - - // --- PATCH as outsider: 404 too. - let (status, body) = f - .request( - "PATCH", - &task_path, - &f.outsider, - Some(r#"{"status":"done"}"#), - ) - .await; - assert_eq!(status, StatusCode::NOT_FOUND, "got {status} {body}"); - - // --- POST comment as outsider: 404. - let (status, body) = f - .request( - "POST", - &format!("{task_path}/events"), - &f.outsider, - Some(r#"{"action":"commented","body":"leak?"}"#), - ) - .await; - assert_eq!(status, StatusCode::NOT_FOUND, "got {status} {body}"); - - // --- Channel-filtered list as outsider: 404, not 200-with-empty. - // The explicit channel filter is itself gated by - // enforce_channel_access (list_tasks), so an outsider cannot even - // probe whether a channel exists — same anti-oracle rule as the - // detail routes. The invisible-channel task is simply unreadable. - let list_path = format!("/api/tasks?channel={}", f.private_channel_id); - let (status, body) = f.request("GET", &list_path, &f.outsider, None).await; - assert_eq!( - status, - StatusCode::NOT_FOUND, - "channel filter must 404 for an invisible channel; got {status} {body}" - ); - - // --- Unfiltered list as outsider: the task must also vanish. - let (status, body) = f.request("GET", "/api/tasks", &f.outsider, None).await; - assert_eq!(status, StatusCode::OK); - let titles: Vec<&str> = body["tasks"] - .as_array() - .map(|tasks| tasks.iter().filter_map(|t| t["title"].as_str()).collect()) - .unwrap_or_default(); - assert!( - !titles.contains(&"route authz probe"), - "task leaked into unfiltered list" - ); - - // --- The owner's list DOES contain it (control for both lists). - let (_, body) = f.request("GET", "/api/tasks", &f.owner, None).await; - let titles: Vec<&str> = body["tasks"] - .as_array() - .map(|tasks| tasks.iter().filter_map(|t| t["title"].as_str()).collect()) - .unwrap_or_default(); - assert!( - titles.contains(&"route authz probe"), - "owner must see the task in the unfiltered list" - ); - - // --- Create bound to the private channel as outsider: 404. - let (status, body) = f - .request( - "POST", - "/api/tasks", - &f.outsider, - Some( - &serde_json::json!({ - "title": "should not exist", - "channel_id": f.private_channel_id, - }) - .to_string(), - ), - ) - .await; - assert_eq!( - status, - StatusCode::NOT_FOUND, - "create must not bind to an invisible channel; got {status} {body}" - ); - } - } - - mod postgres_tests { - use super::route_authz::{fixture, private_channel_assertions}; - - /// The single route-level scenario: a relay member outside a private - /// channel must receive 404 (not 403, not data) on every task route, - /// and the channel-bound task must vanish from listings. The owner's - /// positive control proves the 404s are authz, not breakage. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn private_channel_task_is_invisible_to_non_members_at_the_route() { - let Some(f) = fixture().await else { - eprintln!("SKIP: Postgres/Redis unavailable"); - return; - }; - // Catch assertion panics so cleanup ALWAYS runs, then resume them. - let result = futures::FutureExt::catch_unwind(std::panic::AssertUnwindSafe( - private_channel_assertions(&f), - )) - .await; - drop(f); - if let Err(payload) = result { - std::panic::resume_unwind(payload); - } - } - } -} +#[path = "tasks/tests.rs"] +mod tests; diff --git a/crates/buzz-relay/src/api/tasks/admission.rs b/crates/buzz-relay/src/api/tasks/admission.rs new file mode 100644 index 00000000000..93d9c4e3d06 --- /dev/null +++ b/crates/buzz-relay/src/api/tasks/admission.rs @@ -0,0 +1,62 @@ +//! Primary, freshly authorized admission for a newly accepted signed start. + +use super::*; +use axum::http::{header, HeaderValue}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdmissionQuery { + start_event_id: String, +} + +/// `GET /api/tasks/{task}/attempts/{attempt}/admission`. +/// +/// This read is execution-sensitive. Display state and cache hits must never +/// replace its writer transaction or its exact authenticated worker binding. +/// It is necessary but not sufficient: an adapter must also have received the +/// newly accepted response to its own signed start, never a duplicate/replay. +pub async fn get_fleet_admission( + State(state): State>, + Path((task_id, attempt_id)): Path<(Uuid, String)>, + RawQuery(raw_query): RawQuery, + Query(query): Query, + headers: HeaderMap, +) -> Result<(HeaderMap, Json), (StatusCode, Json)> { + let path = format!("/api/tasks/{task_id}/attempts/{attempt_id}/admission"); + let (tenant, worker) = + authorize_task_request(&state, &headers, "GET", &path, raw_query.as_deref(), None).await?; + if !buzz_core::fleet::valid_attempt_id(&attempt_id) + || query.start_event_id.len() != 64 + || !query + .start_event_id + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) + { + return Err(api_error( + StatusCode::BAD_REQUEST, + "invalid attempt or start event id", + )); + } + let start = hex::decode(&query.start_event_id) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid start event id"))?; + let admission = state + .db + .fleet_start_admission( + tenant.community(), + task_id, + &attempt_id, + &start, + &worker.to_bytes(), + ) + .await + .map_err(|error| match error { + buzz_db::DbError::AccessDenied(_) | buzz_db::DbError::NotFound(_) => api_error( + StatusCode::FORBIDDEN, + "fleet execution admission unavailable", + ), + other => internal_error(&format!("fleet admission: {other}")), + })?; + let mut headers = HeaderMap::new(); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + Ok((headers, Json(admission))) +} diff --git a/crates/buzz-relay/src/api/tasks/fleet_tests.rs b/crates/buzz-relay/src/api/tasks/fleet_tests.rs new file mode 100644 index 00000000000..ef35dbccf4f --- /dev/null +++ b/crates/buzz-relay/src/api/tasks/fleet_tests.rs @@ -0,0 +1,227 @@ +//! Signed HTTP ingress and primary admission exercised over a real local socket. +mod postgres_tests { + use super::super::route_authz::{fixture, Fixture}; + use axum::http::StatusCode; + use buzz_core::{ + cml::{CmlStatus, CmlTask, Lease}, + cml_event::{CmlRole, CmlTransition}, + fleet::{self, FleetReceipt, Qualification, ReceiptStatus}, + }; + use nostr::{Event, EventBuilder, Kind, Tag, Timestamp}; + use serde_json::{json, Value}; + use std::sync::Arc; + + struct Server(tokio::task::JoinHandle<()>); + impl Drop for Server { + fn drop(&mut self) { + self.0.abort(); + } + } + async fn serve(f: &mut Fixture) -> Server { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + f.http_base = Some(format!("http://{}", listener.local_addr().unwrap())); + let state = Arc::get_mut(&mut f.state).unwrap(); + state.nip98_replay = Arc::new(buzz_pubsub::RedisNip98ReplayGuard::new( + state.redis_pool.clone(), + )); + let router = f.router(); + Server(tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + })) + } + fn plan(f: &Fixture) -> CmlTask { + let now = Timestamp::now().as_secs(); + serde_json::from_value(json!({ + "protocol":"buzz-cml","version":1,"id":f.task_id,"title":"Qualify repository","objective":"Read tracked file count", + "status":"planned","priority":"P2","updated_at":now, + "roles":{"planner":f.owner.public_key().to_hex(),"worker":f.outsider.public_key().to_hex(),"reviewer":nostr::Keys::generate().public_key().to_hex(),"fixer":null}, + "git":{"repo":"mfethe1/buzz","branch":"codex/qualify","base_sha":"a".repeat(40),"head_sha":null,"worktree_alias":"buzz"}, + "lease":null,"evidence":[],"blockers":[],"acceptance":[],"review":{"round":0,"max_rounds":3}, + "runtime":{"host_id":null,"last_heartbeat_at":null,"presence":"offline","ttl_seconds":180}, + "extensions":{fleet::EXTENSION:{"target":"mack","machine_id":"mack","repository":"buzz","capability":"qualify","expires_at":now+300,"task_revision":0,"policy_digest":"b".repeat(64)}} + })).unwrap() + } + fn signed( + f: &Fixture, + task: &CmlTask, + transition: CmlTransition, + previous: Option<&Event>, + ) -> Event { + let planner = transition == CmlTransition::Plan; + buzz_sdk::build_cml_transition( + f.private_channel_id, + task, + transition, + if planner { + CmlRole::Planner + } else { + CmlRole::Worker + }, + previous.map(|e| e.id), + ) + .unwrap() + .sign_with_keys(if planner { &f.owner } else { &f.outsider }) + .unwrap() + } + async fn post(f: &Fixture, event: &Event, planner: bool) -> Value { + let (status, body) = f + .request( + "POST", + "/events", + if planner { &f.owner } else { &f.outsider }, + Some(&serde_json::to_string(event).unwrap()), + ) + .await; + println!("fleet HTTP POST: {status} {body}"); + body + } + async fn count(f: &Fixture, event: &Event) -> i64 { + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2") + .bind(f.community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&f.pool) + .await + .unwrap() + } + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn signed_http_atomic_start_ack_and_primary_admission_fail_closed() { + let mut f = fixture().await.expect("real isolated PG/Redis fixture"); + let _server = serve(&mut f).await; + sqlx::query("UPDATE users SET agent_type='hermes',machine_id='mack' WHERE community_id=$1 AND pubkey=$2") + .bind(f.community.as_uuid()).bind(f.outsider.public_key().to_bytes().as_slice()).execute(&f.pool).await.unwrap(); + sqlx::query("INSERT INTO channel_members(community_id,channel_id,pubkey,role) VALUES($1,$2,$3,'bot')") + .bind(f.community.as_uuid()).bind(f.private_channel_id).bind(f.outsider.public_key().to_bytes().as_slice()).execute(&f.pool).await.unwrap(); + let mut task = plan(&f); + let plan = signed(&f, &task, CmlTransition::Plan, None); + assert_ne!(post(&f, &plan, true).await["accepted"], true); + assert_eq!(count(&f, &plan).await, 0); + f.state + .db + .capability_grant( + f.community, + &f.owner.public_key().to_bytes(), + "cross_ssh", + "mack", + &f.owner.public_key().to_bytes(), + ) + .await + .unwrap(); + assert_eq!(post(&f, &plan, true).await["accepted"], true); + let attempt = fleet::attempt_id(f.community, f.task_id, plan.id.as_bytes()); + task.status = CmlStatus::Claimed; + task.lease = Some(Lease { + id: attempt.clone(), + holder: f.outsider.public_key().to_hex(), + issued_at: task.updated_at, + expires_at: task.updated_at + 300, + }); + let claim = signed(&f, &task, CmlTransition::Claim, Some(&plan)); + assert_eq!(post(&f, &claim, false).await["accepted"], true); + task.status = CmlStatus::Working; + let start = signed(&f, &task, CmlTransition::Start, Some(&claim)); + let (one, two) = tokio::join!(post(&f, &start, false), post(&f, &start, false)); + assert_eq!(one["accepted"], true, "{one}"); + assert_eq!(two["accepted"], true, "{two}"); + let fresh = [&one, &two] + .into_iter() + .filter(|v| v["message"].as_str() == Some("")) + .count(); + assert_eq!( + fresh, 1, + "one newly accepted ACK, other duplicate: {one} {two}" + ); + assert_eq!(count(&f, &start).await, 1); + let path = format!( + "/api/tasks/{}/attempts/{attempt}/admission?start_event_id={}", + f.task_id, + start.id.to_hex() + ); + let (status, admission) = f.request("GET", &path, &f.outsider, None).await; + assert_eq!(status, StatusCode::OK, "{admission}"); + assert_eq!(admission["start_event_id"], start.id.to_hex()); + assert_eq!( + f.request("GET", &path, &f.owner, None).await.0, + StatusCode::FORBIDDEN + ); + let mut submitted = task.clone(); + submitted.status = CmlStatus::Review; + submitted.git.head_sha = Some("c".repeat(40)); + let early = signed(&f, &submitted, CmlTransition::Submit, Some(&start)); + assert_ne!(post(&f, &early, false).await["accepted"], true); + assert_eq!(count(&f, &early).await, 0); + f.state + .db + .capability_revoke( + f.community, + &f.owner.public_key().to_bytes(), + "cross_ssh", + "mack", + &f.owner.public_key().to_bytes(), + ) + .await + .unwrap(); + assert_eq!( + f.request("GET", &path, &f.outsider, None).await.0, + StatusCode::FORBIDDEN + ); + let (status, display) = f + .request("GET", &format!("/api/tasks/{}", f.task_id), &f.owner, None) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(display["attempts"][0]["state"], "started"); + let wire = FleetReceipt { + attempt_id: attempt.clone(), + task_id: f.task_id, + plan_event_id: plan.id.to_hex(), + start_event_id: Some(start.id.to_hex()), + machine_id: "mack".into(), + policy_digest: "b".repeat(64), + status: ReceiptStatus::Success, + qualification: Some(Qualification { + repository: "mfethe1/buzz".into(), + head_sha: "c".repeat(40), + tracked_files: 7, + python: "3.14".into(), + }), + error: None, + completed_at: Timestamp::now().as_secs(), + }; + let receipt = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_JOB_RESULT as u16), + wire.to_canonical_json().unwrap(), + ) + .tags([ + Tag::parse(["protocol", fleet::RECEIPT_PROTOCOL, "1"]).unwrap(), + Tag::parse(["h", &f.private_channel_id.to_string()]).unwrap(), + Tag::parse(["d", &attempt]).unwrap(), + ]) + .sign_with_keys(&f.outsider) + .unwrap(); + // Revocation blocks new execution, while an already completed worker may + // still append its verified terminal fact through normal member ingress. + assert_eq!(post(&f, &receipt, false).await["accepted"], true); + assert_ne!(post(&f, &early, false).await["accepted"], true); + assert_eq!(count(&f, &early).await, 0); + submitted.evidence.push(buzz_core::cml::Evidence { + kind: "fleet-qualification-receipt".into(), + reference: receipt.id.to_hex(), + }); + let mut wrong = submitted.clone(); + wrong.git.head_sha = Some("d".repeat(40)); + let wrong = signed(&f, &wrong, CmlTransition::Submit, Some(&start)); + assert_ne!(post(&f, &wrong, false).await["accepted"], true); + assert_eq!(count(&f, &wrong).await, 0); + let valid = signed(&f, &submitted, CmlTransition::Submit, Some(&start)); + assert_eq!(post(&f, &valid, false).await["accepted"], true); + let (_, display) = f + .request("GET", &format!("/api/tasks/{}", f.task_id), &f.owner, None) + .await; + assert_eq!(display["attempts"][0]["state"], "success"); + assert_eq!( + display["attempts"][0]["receipt_event_id"], + receipt.id.to_hex() + ); + println!("SIGNED_HTTP_FLEET_PASS: ungranted denied; one fresh start ACK; wrong worker and revoked grant denied; signed terminal outcome persisted"); + } +} diff --git a/crates/buzz-relay/src/api/tasks/machine_tests.rs b/crates/buzz-relay/src/api/tasks/machine_tests.rs new file mode 100644 index 00000000000..51ab3f28fc6 --- /dev/null +++ b/crates/buzz-relay/src/api/tasks/machine_tests.rs @@ -0,0 +1,386 @@ +//! Actual private machine HTTP ingress/readback with real PostgreSQL and Redis. +mod postgres_tests { + use super::super::route_authz::{fixture, Fixture}; + use axum::http::StatusCode; + use futures_util::StreamExt; + use nostr::{Event, EventBuilder, Keys, Kind, Timestamp}; + use serde_json::{json, Value}; + use std::{sync::Arc, time::Duration}; + use uuid::Uuid; + + struct Server(tokio::task::JoinHandle<()>); + impl Drop for Server { + fn drop(&mut self) { + self.0.abort(); + } + } + async fn serve(f: &mut Fixture) -> Server { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + f.http_base = Some(format!("http://{}", listener.local_addr().unwrap())); + let state = Arc::get_mut(&mut f.state).unwrap(); + state.nip98_replay = Arc::new(buzz_pubsub::RedisNip98ReplayGuard::new( + state.redis_pool.clone(), + )); + let router = f.router(); + Server(tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + })) + } + fn enrollment(f: &Fixture, machine: Uuid, coordinator: &Keys, conditions: &str) -> Event { + let proof = + buzz_sdk::nip_oa::compute_auth_tag(&f.owner, &coordinator.public_key(), conditions) + .unwrap(); + let mut payload = json!({"version":1,"community_id":f.community.as_uuid(),"machine_id":machine,"coordinator_pubkey":coordinator.public_key().to_hex(),"label":format!("Private computer marker {machine}"),"runtime":"hermes","owner_auth":serde_json::from_str::(&proof).unwrap()}); + let mut consent = payload.clone(); + consent["owner_pubkey"] = json!(f.owner.public_key().to_hex()); + consent["expires_at"] = json!(Timestamp::now().as_secs() + 300); + payload["coordinator_consent"] = serde_json::to_value( + EventBuilder::new(Kind::Custom(47212), consent.to_string()) + .sign_with_keys(coordinator) + .unwrap(), + ) + .unwrap(); + EventBuilder::new(Kind::Custom(47210), payload.to_string()) + .sign_with_keys(&f.owner) + .unwrap() + } + async fn post(f: &Fixture, event: &Event, signer: &Keys) -> (StatusCode, Value) { + f.request( + "POST", + "/events", + signer, + Some(&serde_json::to_string(event).unwrap()), + ) + .await + } + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn signed_machine_enrollment_observation_owner_reads_and_no_public_side_effects() { + let mut f = fixture().await.expect("owned PG/Redis"); + let _server = serve(&mut f).await; + let machine = Uuid::new_v4(); + let enrolled = enrollment(&f, machine, &f.outsider, "kind=47210"); + let mut local = f.state.pubsub.subscribe_local(); + let redis_url = std::env::var("BUZZ_TEST_REDIS_URL").expect("owned Redis URL"); + let mut redis = deadpool_redis::redis::Client::open(redis_url) + .unwrap() + .get_async_pubsub() + .await + .unwrap(); + redis.psubscribe("*").await.unwrap(); + let unrelated = buzz_core::TenantContext::resolved( + buzz_core::CommunityId::from_uuid(Uuid::new_v4()), + "unrelated-privacy-fixture.invalid", + ); + let observers = f + .state + .pubsub + .publish_conn_control( + &unrelated, + &buzz_pubsub::conn_control::ConnControl::DisconnectCommunity, + ) + .await + .unwrap(); + assert!( + observers >= 1, + "wildcard observer must receive unrelated traffic" + ); + assert_eq!(post(&f, &enrolled, &f.owner).await.1["accepted"], true); + assert!(post(&f, &enrolled, &f.owner).await.1["message"] + .as_str() + .unwrap() + .starts_with("duplicate:")); + let path = format!("/api/machines/{machine}"); + let (status, registered) = f.request("GET", &path, &f.owner, None).await; + assert_eq!(status, StatusCode::OK, "{registered}"); + assert_eq!(registered["enrollment_event"]["id"], enrolled.id.to_hex()); + assert_eq!(registered["fresh"], false); + assert_eq!( + f.request("GET", &path, &f.outsider, None).await.0, + StatusCode::NOT_FOUND + ); + assert_eq!( + f.request("GET", "/api/machines", &f.outsider, None).await.1["machines"], + json!([]) + ); + let dev = reqwest::Client::new() + .get(format!("{}{path}", f.http_base.as_ref().unwrap())) + .header("Host", &f.host) + .header("X-Pubkey", f.owner.public_key().to_hex()) + .send() + .await + .unwrap(); + assert_eq!( + dev.status(), + StatusCode::UNAUTHORIZED, + "dev fallback must stay closed" + ); + let obs=EventBuilder::new(Kind::Custom(47211),json!({"version":1,"community_id":f.community.as_uuid(),"machine_id":machine,"registration_event_id":enrolled.id.to_hex(),"sequence":1,"state":"ready"}).to_string()).sign_with_keys(&f.outsider).unwrap(); + assert_eq!(post(&f, &obs, &f.outsider).await.1["accepted"], true); + let current = f.request("GET", &path, &f.owner, None).await.1; + assert_eq!(current["fresh"], true); + assert_eq!(current["reported_state"], "ready"); + assert!(post(&f, &obs, &f.outsider).await.1["message"] + .as_str() + .unwrap() + .starts_with("duplicate:")); + // Only the response clock may advance. Replay must preserve all durable + // projection fields, including observation expiry and signed events. + let mut replayed = f.request("GET", &path, &f.owner, None).await.1; + let mut current = current; + assert!(replayed + .as_object_mut() + .unwrap() + .remove("server_now") + .is_some()); + assert!(current + .as_object_mut() + .unwrap() + .remove("server_now") + .is_some()); + assert_eq!(replayed, current); + for filter in [ + json!({"ids":[enrolled.id.to_hex(),obs.id.to_hex()]}), + json!({"kinds":[47210,47211]}), + json!({"kinds":[9,47210,47211]}), + json!({}), + json!({"kinds":[47210,47211],"search":"Private"}), + json!({"kinds":[47210,47211],"feed_types":["activity","mentions","needs_action"]}), + json!({"ids":[enrolled.id.to_hex()],"include_history":true}), + ] { + for keys in [&f.owner, &f.outsider] { + let (status, body) = f + .request("POST", "/query", keys, Some(&json!([filter]).to_string())) + .await; + assert!( + [StatusCode::OK, StatusCode::FORBIDDEN].contains(&status), + "{status} {body}" + ); + let raw = body.to_string(); + assert!( + !raw.contains("Private computer marker") + && !raw.contains(&enrolled.id.to_hex()) + && !raw.contains(&obs.id.to_hex()), + "private query leak: {body}" + ); + } + } + let (status, count) = f + .request( + "POST", + "/count", + &f.owner, + Some(&json!([{"kinds":[47210,47211]}]).to_string()), + ) + .await; + assert_eq!(status, StatusCode::OK, "{count}"); + assert_eq!(count["count"], 0); + for table in [ + "events", + "event_mentions", + "push_match_queue", + "workflow_runs", + "agent_capability_grants", + "agent_capability_events", + ] { + let sql = format!("SELECT count(*) FROM {table} WHERE community_id=$1"); + let count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(sql)) + .bind(f.community.as_uuid()) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!(count, 0, "{table} must remain empty"); + } + assert!( + tokio::time::timeout(Duration::from_millis(100), local.recv()) + .await + .is_err(), + "local fanout leak" + ); + // Other PostgreSQL fixtures share Redis and may publish concurrently. + // Keep the wildcard subscription: private data on a wrong tenant's + // channel must still fail, while unrelated control traffic must not. + let private_markers = [ + f.community.as_uuid().to_string(), + machine.to_string(), + enrolled.id.to_hex(), + obs.id.to_hex(), + ]; + let deadline = tokio::time::Instant::now() + Duration::from_millis(100); + let mut messages = redis.on_message(); + loop { + match tokio::time::timeout_at(deadline, messages.next()).await { + Err(_) => break, + Ok(None) => panic!("Redis privacy observer closed before its deadline"), + Ok(Some(message)) => { + let channel = message.get_channel_name(); + let payload = String::from_utf8_lossy(message.get_payload_bytes()); + assert!( + !private_markers + .iter() + .any(|marker| channel.contains(marker) || payload.contains(marker)), + "Redis fanout leak for private machine fixture" + ); + } + } + } + f.state.db.validate_deletion_catalog().await.unwrap(); + eprintln!("PASS real signed HTTP enrollment→observation→private owner GET; X-Pubkey401, foreign owner404, duplicate expiry stable, ordinary query/COUNT/search/feed and no fixture data in local/Redis fanout; no grants/push/workflow rows"); + } + + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn machine_owner_proof_audience_signature_timestamp_and_home_rejections_are_atomic() { + let mut f = fixture().await.expect("owned PG/Redis"); + let _server = serve(&mut f).await; + for conditions in ["kind=27235", "kind=47211", "kind=47210&created_at<1"] { + let event = enrollment(&f, Uuid::new_v4(), &f.outsider, conditions); + assert_ne!( + post(&f, &event, &f.owner).await.1["accepted"], + true, + "{conditions}" + ); + } + let event = enrollment(&f, Uuid::new_v4(), &f.outsider, "kind=47210"); + let payload: Value = serde_json::from_str(&event.content).unwrap(); + // Owner-only assertions must not capture an existing human/unowned key. + for case in [ + "missing", + "wrong_signer", + "tampered", + "expired", + "wrong_metadata", + "foreign_consent", + ] { + let mut bad = payload.clone(); + let consent: Event = + serde_json::from_value(bad["coordinator_consent"].clone()).unwrap(); + let mut consent_body: Value = serde_json::from_str(&consent.content).unwrap(); + match case { + "missing" => { + bad.as_object_mut().unwrap().remove("coordinator_consent"); + } + "wrong_signer" => { + bad["coordinator_consent"] = serde_json::to_value( + EventBuilder::new(Kind::Custom(47212), consent.content) + .sign_with_keys(&f.owner) + .unwrap(), + ) + .unwrap() + } + "tampered" => { + bad["coordinator_consent"]["content"] = json!(format!("{} ", consent.content)); + } + "expired" => { + consent_body["expires_at"] = json!(Timestamp::now().as_secs() - 1); + bad["coordinator_consent"] = serde_json::to_value( + EventBuilder::new(Kind::Custom(47212), consent_body.to_string()) + .sign_with_keys(&f.outsider) + .unwrap(), + ) + .unwrap(); + } + "wrong_metadata" => bad["label"] = json!("Changed without coordinator"), + "foreign_consent" => { + consent_body["community_id"] = json!(Uuid::new_v4()); + bad["coordinator_consent"] = serde_json::to_value( + EventBuilder::new(Kind::Custom(47212), consent_body.to_string()) + .sign_with_keys(&f.outsider) + .unwrap(), + ) + .unwrap(); + } + _ => unreachable!(), + } + let bad = EventBuilder::new(Kind::Custom(47210), bad.to_string()) + .sign_with_keys(&f.owner) + .unwrap(); + assert_ne!(post(&f, &bad, &f.owner).await.1["accepted"], true, "{case}"); + } + let standalone: Event = + serde_json::from_value(payload["coordinator_consent"].clone()).unwrap(); + assert_ne!(post(&f, &standalone, &f.outsider).await.1["accepted"], true); + + let foreign = EventBuilder::new(Kind::Custom(47210), event.content.clone()) + .sign_with_keys(&f.outsider) + .unwrap(); + assert_ne!(post(&f, &foreign, &f.outsider).await.1["accepted"], true); + let mut wrong = payload.clone(); + wrong["community_id"] = json!(Uuid::new_v4()); + let wrong = EventBuilder::new(Kind::Custom(47210), wrong.to_string()) + .sign_with_keys(&f.owner) + .unwrap(); + assert_ne!(post(&f, &wrong, &f.owner).await.1["accepted"], true); + let mut invalid = event.clone(); + invalid.content.push(' '); + assert_ne!(post(&f, &invalid, &f.owner).await.1["accepted"], true); + assert_eq!( + f.request("GET", "/api/machines", &f.owner, None).await.1["machines"], + json!([]) + ); + let materialized:bool=sqlx::query_scalar("SELECT agent_owner_pubkey IS NOT NULL OR agent_type IS NOT NULL OR machine_id IS NOT NULL FROM users WHERE community_id=$1 AND pubkey=$2").bind(f.community.as_uuid()).bind(f.outsider.public_key().to_bytes().as_slice()).fetch_one(&f.pool).await.unwrap(); + assert!(!materialized); + assert_eq!(post(&f, &event, &f.owner).await.1["accepted"], true); + let delayed=EventBuilder::new(Kind::Custom(47211),json!({"version":1,"community_id":f.community.as_uuid(),"machine_id":payload["machine_id"],"registration_event_id":event.id.to_hex(),"sequence":10,"state":"ready"}).to_string()).custom_created_at(Timestamp::from(Timestamp::now().as_secs()-31)).sign_with_keys(&f.outsider).unwrap(); + assert_ne!(post(&f, &delayed, &f.outsider).await.1["accepted"], true); + let replacement = enrollment( + &f, + Uuid::parse_str(payload["machine_id"].as_str().unwrap()).unwrap(), + &Keys::generate(), + "kind=47210", + ); + assert_ne!(post(&f, &replacement, &f.owner).await.1["accepted"], true); + let count: i64 = + sqlx::query_scalar("SELECT count(*) FROM machine_control_events WHERE community_id=$1") + .bind(f.community.as_uuid()) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!(count, 1); + eprintln!("PASS invalid action proof, signer, signature, tenant, delayed observation and replacement leave no partial machine materialization"); + } + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn standalone_machine_consent_does_not_materialize_transport_owner() { + let mut f = fixture().await.expect("owned PG/Redis"); + Arc::make_mut(&mut Arc::get_mut(&mut f.state).unwrap().config).allow_nip_oa_auth = true; + let _server = serve(&mut f).await; + let coordinator = Keys::generate(); + let outer = enrollment(&f, Uuid::new_v4(), &coordinator, "kind=47210"); + let document: Value = serde_json::from_str(&outer.content).unwrap(); + let body = document["coordinator_consent"].to_string(); + let proof = + buzz_sdk::nip_oa::compute_auth_tag(&f.owner, &coordinator.public_key(), "kind=27235") + .unwrap(); + let auth = super::super::route_authz::nip98_auth_header( + &coordinator, + "POST", + &format!("https://{}/events", f.host), + body.as_bytes(), + ); + let response = reqwest::Client::new() + .post(format!("{}/events", f.http_base.as_ref().unwrap())) + .header("Host", &f.host) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .header("x-auth-tag", proof) + .body(body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body: Value = response.json().await.unwrap(); + assert_ne!(body["accepted"], true); + let materialized: i64 = + sqlx::query_scalar("SELECT count(*) FROM users WHERE community_id=$1 AND pubkey=$2") + .bind(f.community.as_uuid()) + .bind(coordinator.public_key().to_bytes().as_slice()) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!( + materialized, 0, + "rejected standalone consent must not materialize the transport owner" + ); + } +} diff --git a/crates/buzz-relay/src/api/tasks/notifications_tests.rs b/crates/buzz-relay/src/api/tasks/notifications_tests.rs new file mode 100644 index 00000000000..5a91949277e --- /dev/null +++ b/crates/buzz-relay/src/api/tasks/notifications_tests.rs @@ -0,0 +1,1005 @@ +//! Actual signed HTTP mutations observed over NIP-42 authenticated WebSockets. + +mod postgres_tests { + use super::super::route_authz::{fixture, Fixture}; + use axum::http::StatusCode; + use futures_util::{SinkExt, StreamExt}; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use serde_json::{json, Value}; + use std::{sync::Arc, time::Duration}; + use tokio_tungstenite::{ + tungstenite::{client::IntoClientRequest, Message}, + MaybeTlsStream, WebSocketStream, + }; + + type Socket = WebSocketStream>; + + async fn next_json(socket: &mut Socket) -> Value { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + match socket + .next() + .await + .expect("socket remains open") + .expect("websocket read") + { + Message::Text(text) => return serde_json::from_str(&text).expect("JSON frame"), + Message::Ping(data) => socket.send(Message::Pong(data)).await.expect("pong"), + other => panic!("unexpected frame: {other:?}"), + } + } + }) + .await + .expect("bounded websocket response") + } + + async fn connect(base: &str, host: &str, keys: Option<&Keys>) -> Socket { + connect_with_delegation(base, host, keys, None).await + } + + async fn connect_with_delegation( + base: &str, + host: &str, + keys: Option<&Keys>, + owner: Option<&Keys>, + ) -> Socket { + let mut request = base + .replacen("http://", "ws://", 1) + .into_client_request() + .expect("WS request"); + request + .headers_mut() + .insert("Host", host.parse().expect("host")); + let (mut socket, _) = tokio_tungstenite::connect_async(request) + .await + .expect("upgrade"); + let challenge = next_json(&mut socket).await; + assert_eq!(challenge[0], "AUTH"); + if let Some(keys) = keys { + let mut tags = vec![ + Tag::parse(["relay", &format!("wss://{host}")]).expect("relay tag"), + Tag::parse(["challenge", challenge[1].as_str().expect("challenge")]) + .expect("challenge tag"), + ]; + if let Some(owner) = owner { + let signed = buzz_sdk::nip_oa::compute_auth_tag(owner, &keys.public_key(), "") + .expect("signed delegation"); + tags.push(buzz_sdk::nip_oa::parse_auth_tag(&signed).expect("delegation tag")); + } + let event = EventBuilder::new(Kind::Authentication, "") + .tags(tags) + .sign_with_keys(keys) + .expect("signed NIP-42"); + let id = event.id.to_hex(); + socket + .send(Message::Text(json!(["AUTH", event]).to_string().into())) + .await + .expect("AUTH"); + let ack = next_json(&mut socket).await; + assert_eq!(ack[0], "OK"); + assert_eq!(ack[1], id); + assert_eq!(ack[2], true, "{ack}"); + } + socket + } + + async fn expect_sync(socket: &mut Socket, channel: uuid::Uuid) { + assert_eq!( + next_json(socket).await, + json!(["BUZZ_TASKS_SYNC_REQUIRED", channel]) + ); + } + + async fn expect_community_sync(socket: &mut Socket) { + assert_eq!( + next_json(socket).await, + json!(["BUZZ_TASKS_SYNC_REQUIRED", null]) + ); + } + + async fn expect_no_notification(socket: &mut Socket) { + // Heartbeats are independent of task activity. Reject every application + // frame and unexpected close while servicing the ordinary Ping/Pong flow. + let outcome = tokio::time::timeout(Duration::from_millis(100), async { + loop { + match socket.next().await { + Some(Ok(Message::Ping(data))) => { + socket.send(Message::Pong(data)).await.expect("pong"); + } + frame => panic!("unauthorized or unchanged client received {frame:?}"), + } + } + }) + .await; + assert!(outcome.is_err()); + } + + struct Server(tokio::task::JoinHandle<()>); + impl Drop for Server { + fn drop(&mut self) { + self.0.abort(); + } + } + + struct Background(Vec>); + impl Drop for Background { + fn drop(&mut self) { + for handle in &self.0 { + handle.abort(); + } + } + } + + async fn start_conn_control(state: Arc) -> Background { + start_conn_control_gated(state, None).await + } + + async fn start_conn_control_gated( + state: Arc, + gate: Option>, + ) -> Background { + let mut rx = state.pubsub.subscribe_conn_control(); + let control_rx = state.pubsub.subscribe_conn_control(); + let subscriber = { + let pubsub = state.pubsub.clone(); + tokio::spawn(async move { + if let Some(gate) = gate { + gate.await.expect("subscriber startup released"); + } + pubsub.run_conn_control_subscriber().await; + }) + }; + // A fresh, nonexistent community makes the probe disjoint from every + // fixture socket. Observe it on this exact subscriber, not merely the + // Redis PUBLISH subscriber count (which can describe another relay). + let probe_ctx = buzz_core::TenantContext::resolved( + buzz_core::CommunityId::from_uuid(uuid::Uuid::new_v4()), + "readiness.invalid", + ); + let probe_community = probe_ctx.community(); + let publisher = state.pubsub.clone(); + let (ready_tx, mut ready_rx) = tokio::sync::oneshot::channel(); + let consumer = tokio::spawn(state.run_connection_control(control_rx)); + let readiness = tokio::spawn(async move { + let mut ready_tx = Some(ready_tx); + while let Ok(scoped) = rx.recv().await { + if scoped.community_id == probe_community + && scoped.command == buzz_pubsub::conn_control::ConnControl::DisconnectCommunity + { + if let Some(tx) = ready_tx.take() { + let _ = tx.send(()); + } + continue; + } + } + }); + // Own the handles before awaiting readiness, so timeout/unwind also + // aborts both tasks. Probe retries never replay HTTP task mutations. + let background = Background(vec![subscriber, consumer, readiness]); + tokio::time::timeout(Duration::from_secs(5), async { + let mut interval = tokio::time::interval(Duration::from_millis(50)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + result = &mut ready_rx => { + result.expect("readiness consumer remains alive"); + break; + } + _ = interval.tick() => { + publisher.publish_conn_control( + &probe_ctx, + &buzz_pubsub::conn_control::ConnControl::DisconnectCommunity, + ).await.expect("publish isolated readiness probe"); + } + } + } + }) + .await + .expect("this Redis subscriber must observe readiness within five seconds"); + background + } + + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn connection_control_readiness_waits_for_the_actual_subscriber() { + let f = fixture().await.expect("Postgres and Redis fixture"); + // Another healthy subscriber must not satisfy this relay's readiness. + let other = peer_state(&f).await; + let _other_background = start_conn_control(other).await; + let (release, gate) = tokio::sync::oneshot::channel(); + let startup = start_conn_control_gated(f.state.clone(), Some(gate)); + tokio::pin!(startup); + tokio::select! { + _ = &mut startup => panic!("readiness reported before the subscriber was released"), + _ = tokio::time::sleep(Duration::from_millis(250)) => {} + } + release.send(()).expect("release subscriber startup"); + let _background = tokio::time::timeout(Duration::from_secs(5), startup) + .await + .expect("actual Redis readiness must be bounded"); + } + + async fn serve(f: &mut Fixture) -> Server { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + f.http_base = Some(format!( + "http://{}", + listener.local_addr().expect("address") + )); + // Unlike the older route fixture, this test exercises the actual Redis replay guard. + let state = Arc::get_mut(&mut f.state).expect("exclusive state before serve"); + state.nip98_replay = Arc::new(buzz_pubsub::RedisNip98ReplayGuard::new( + state.redis_pool.clone(), + )); + let router = f.router(); + Server(tokio::spawn(async move { + axum::serve(listener, router).await.expect("serve"); + })) + } + + async fn peer_state(f: &Fixture) -> Arc { + let config = (*f.state.config).clone(); + let redis_pool = f.state.redis_pool.clone(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("peer pubsub"), + ); + let audit = buzz_audit::AuditService::new(f.pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(f.pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + f.state.db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("peer media"); + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + f.state.db.clone(), + redis_pool.clone(), + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(buzz_pubsub::RedisNip98ReplayGuard::new(redis_pool)); + Arc::new(state) + } + + async fn serve_state(state: Arc) -> (Server, String) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let base = format!("http://{}", listener.local_addr().expect("address")); + let router = crate::router::build_router(state); + let server = Server(tokio::spawn(async move { + axum::serve(listener, router).await.expect("serve"); + })); + (server, base) + } + + async fn request_at( + f: &Fixture, + base: &str, + method: &str, + path: &str, + keys: &Keys, + body: Option<&str>, + ) -> (StatusCode, Value) { + let body_bytes = body.map(str::as_bytes).unwrap_or_default(); + let auth = super::super::route_authz::nip98_auth_header( + keys, + method, + &format!("https://{}{path}", f.host), + body_bytes, + ); + let response = reqwest::Client::new() + .request(method.parse().expect("method"), format!("{base}{path}")) + .header(axum::http::header::HOST, &f.host) + .header(axum::http::header::AUTHORIZATION, auth) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(body_bytes.to_vec()) + .send() + .await + .expect("HTTP response"); + let status = response.status(); + let json = response.json().await.expect("HTTP JSON response"); + (status, json) + } + + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn stored_agent_owner_does_not_authorize_a_revoked_direct_session() { + let mut f = fixture().await.expect("Postgres and Redis fixture"); + let state = Arc::get_mut(&mut f.state).expect("exclusive fixture state"); + let mut config = (*state.config).clone(); + config.allow_nip_oa_auth = true; + state.config = Arc::new(config); + state + .db + .add_relay_member( + f.community, + &f.outsider.public_key().to_hex(), + "member", + None, + ) + .await + .expect("direct agent membership"); + assert!(state + .db + .set_agent_owner( + f.community, + &f.outsider.public_key().to_bytes(), + &f.owner.public_key().to_bytes(), + ) + .await + .expect("stored owner relationship")); + let _server = serve(&mut f).await; + let base = f.http_base.as_deref().expect("HTTP base"); + let mut owner = connect(base, &f.host, Some(&f.owner)).await; + // This NIP-42 session deliberately has no NIP-OA auth tag. + let mut agent = connect(base, &f.host, Some(&f.outsider)).await; + let (status, created) = f + .request( + "POST", + "/api/tasks", + &f.owner, + Some(r#"{"title":"before direct access revocation"}"#), + ) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + expect_community_sync(&mut owner).await; + expect_community_sync(&mut agent).await; + f.state + .db + .remove_relay_member(f.community, &f.outsider.public_key().to_hex()) + .await + .expect("revoke direct agent membership"); + let (status, denied) = f.request("GET", "/api/tasks", &f.outsider, None).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{denied}"); + let (status, created) = f + .request( + "POST", + "/api/tasks", + &f.owner, + Some(r#"{"title":"after direct access revocation"}"#), + ) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + expect_community_sync(&mut owner).await; + expect_no_notification(&mut agent).await; + } + + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn verified_delegation_is_session_scoped_and_owner_revocation_is_immediate() { + let mut f = fixture().await.expect("Postgres and Redis fixture"); + let state = Arc::get_mut(&mut f.state).expect("exclusive fixture state"); + let mut config = (*state.config).clone(); + config.allow_nip_oa_auth = true; + state.config = Arc::new(config); + let agent = Keys::generate(); + state + .db + .add_relay_member(f.community, &agent.public_key().to_hex(), "member", None) + .await + .expect("direct agent membership"); + let _server = serve(&mut f).await; + let base = f.http_base.as_deref().expect("HTTP base"); + let mut direct = connect(base, &f.host, Some(&agent)).await; + f.state + .db + .remove_relay_member(f.community, &agent.public_key().to_hex()) + .await + .expect("revoke direct membership"); + // Same key, separate live connection, and an actual owner-signed auth tag. + let mut delegated = + connect_with_delegation(base, &f.host, Some(&agent), Some(&f.outsider)).await; + let mut publisher = connect(base, &f.host, Some(&f.owner)).await; + let (status, created) = f + .request( + "POST", + "/api/tasks", + &f.owner, + Some(r#"{"title":"verified delegation receives update"}"#), + ) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + expect_community_sync(&mut publisher).await; + expect_community_sync(&mut delegated).await; + expect_no_notification(&mut direct).await; + + f.state + .db + .remove_relay_member(f.community, &f.outsider.public_key().to_hex()) + .await + .expect("revoke delegation owner membership"); + let (status, created) = f + .request( + "POST", + "/api/tasks", + &f.owner, + Some(r#"{"title":"revoked owner cannot receive update"}"#), + ) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + expect_community_sync(&mut publisher).await; + expect_no_notification(&mut delegated).await; + expect_no_notification(&mut direct).await; + } + + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn community_tasks_and_channel_tasks_refresh_another_relay_instance() { + let mut f = fixture().await.expect("Postgres and Redis fixture"); + let other = fixture().await.expect("second isolated community"); + let _server_a = serve(&mut f).await; + let peer = peer_state(&f).await; + let _peer_conn_control = start_conn_control(peer.clone()).await; + let (_server_b, base_b) = serve_state(peer).await; + + let mut owner = connect(&base_b, &f.host, Some(&f.owner)).await; + let mut outsider = connect(&base_b, &f.host, Some(&f.outsider)).await; + let mut unauthenticated = connect(&base_b, &f.host, None).await; + let mut foreign = connect(&base_b, &other.host, Some(&other.owner)).await; + + let community_body = json!({"title":"community task from relay A"}).to_string(); + let (status, created) = f + .request("POST", "/api/tasks", &f.owner, Some(&community_body)) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + expect_community_sync(&mut owner).await; + expect_community_sync(&mut outsider).await; + expect_no_notification(&mut unauthenticated).await; + expect_no_notification(&mut foreign).await; + let community_path = format!( + "/api/tasks/{}", + created["id"].as_str().expect("community task id") + ); + let (status, detail) = + request_at(&f, &base_b, "GET", &community_path, &f.owner, None).await; + assert_eq!(status, StatusCode::OK, "{detail}"); + assert_eq!(detail["task"]["title"], "community task from relay A"); + + let (status, updated) = f + .request( + "PATCH", + &community_path, + &f.owner, + Some(r#"{"priority":4,"expected_revision":0}"#), + ) + .await; + assert_eq!(status, StatusCode::OK, "{updated}"); + expect_community_sync(&mut owner).await; + expect_community_sync(&mut outsider).await; + let community_event_path = format!("{community_path}/events"); + let (status, event) = f + .request( + "POST", + &community_event_path, + &f.owner, + Some(r#"{"body":"community event from relay A"}"#), + ) + .await; + assert_eq!(status, StatusCode::OK, "{event}"); + expect_community_sync(&mut owner).await; + expect_community_sync(&mut outsider).await; + let (status, detail) = + request_at(&f, &base_b, "GET", &community_path, &f.owner, None).await; + assert_eq!(status, StatusCode::OK, "{detail}"); + assert_eq!(detail["task"]["revision"], 1); + assert!(detail["events"].as_array().is_some_and(|events| events + .iter() + .any(|event| event["body"] == "community event from relay A"))); + + let channel_body = json!({ + "title":"channel task from relay A", + "channel_id":f.private_channel_id, + }) + .to_string(); + let (status, channel_task) = f + .request("POST", "/api/tasks", &f.owner, Some(&channel_body)) + .await; + assert_eq!(status, StatusCode::OK, "{channel_task}"); + expect_sync(&mut owner, f.private_channel_id).await; + expect_no_notification(&mut outsider).await; + + f.state + .db + .add_member( + f.community, + f.private_channel_id, + &f.outsider.public_key().to_bytes(), + buzz_core::channel::MemberRole::Member, + Some(&f.owner.public_key().to_bytes()), + ) + .await + .expect("grant private-channel membership"); + let channel_path = format!( + "/api/tasks/{}", + channel_task["id"].as_str().expect("channel task id") + ); + let (status, updated) = f + .request( + "PATCH", + &channel_path, + &f.owner, + Some(r#"{"priority":2,"expected_revision":0}"#), + ) + .await; + assert_eq!(status, StatusCode::OK, "{updated}"); + expect_sync(&mut owner, f.private_channel_id).await; + expect_sync(&mut outsider, f.private_channel_id).await; + + f.state + .db + .remove_member( + f.community, + f.private_channel_id, + &f.outsider.public_key().to_bytes(), + &f.owner.public_key().to_bytes(), + ) + .await + .expect("revoke private-channel membership"); + let channel_event_path = format!("{channel_path}/events"); + let (status, event) = f + .request( + "POST", + &channel_event_path, + &f.owner, + Some(r#"{"body":"private event after revocation"}"#), + ) + .await; + assert_eq!(status, StatusCode::OK, "{event}"); + expect_sync(&mut owner, f.private_channel_id).await; + expect_no_notification(&mut outsider).await; + + f.state + .db + .remove_relay_member(f.community, &f.outsider.public_key().to_hex()) + .await + .expect("revoke relay membership"); + let (status, updated) = f + .request( + "PATCH", + &community_path, + &f.owner, + Some(r#"{"priority":5,"expected_revision":1}"#), + ) + .await; + assert_eq!(status, StatusCode::OK, "{updated}"); + expect_community_sync(&mut owner).await; + expect_no_notification(&mut outsider).await; + + let tenant = buzz_core::TenantContext::resolved(f.community, &f.host); + let duplicate = buzz_pubsub::conn_control::ConnControl::InvalidateTasks { + channel_id: None, + origin_generation: f.state.task_invalidation_generation, + }; + f.state + .pubsub + .publish_conn_control(&tenant, &duplicate) + .await + .expect("first duplicate advisory"); + f.state + .pubsub + .publish_conn_control(&tenant, &duplicate) + .await + .expect("second duplicate advisory"); + expect_community_sync(&mut owner).await; + expect_community_sync(&mut owner).await; + expect_no_notification(&mut outsider).await; + expect_no_notification(&mut unauthenticated).await; + expect_no_notification(&mut foreign).await; + } + + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn committed_task_http_mutations_notify_only_authorized_websockets() { + let mut f = fixture().await.expect("Postgres and Redis fixture"); + let other = fixture().await.expect("second isolated community"); + let _server = serve(&mut f).await; + let base = f.http_base.as_deref().expect("server base"); + let mut owner = connect(base, &f.host, Some(&f.owner)).await; + let mut outsider = connect(base, &f.host, Some(&f.outsider)).await; + let mut unauthenticated = connect(base, &f.host, None).await; + let mut foreign = connect(base, &other.host, Some(&other.owner)).await; + // A stale cached allow must not become a task-existence oracle. + f.state.accessible_channels_cache.insert( + (f.community, f.outsider.public_key().to_bytes().to_vec()), + vec![f.private_channel_id], + ); + + let path = format!("/api/tasks/{}", f.task_id); + // Hold the task UPDATE inside its transaction. Notification before the + // commit would disclose an event while its row is still invisible. + let gate = (uuid::Uuid::new_v4().as_u128() & i64::MAX as u128) as i64; + let mut lock = f.pool.acquire().await.expect("gate connection"); + sqlx::query("SELECT pg_advisory_lock($1)") + .bind(gate) + .execute(&mut *lock) + .await + .expect("hold gate"); + let ddl = format!( + "CREATE FUNCTION task_notification_commit_gate() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_advisory_xact_lock({gate}); RETURN NEW; END $$; CREATE TRIGGER task_notification_commit_gate AFTER UPDATE ON tasks FOR EACH ROW EXECUTE FUNCTION task_notification_commit_gate()" + ); + sqlx::raw_sql(sqlx::AssertSqlSafe(ddl)) + .execute(&f.pool) + .await + .expect("install gate"); + let (response, ()) = tokio::join!( + f.request( + "PATCH", + &path, + &f.owner, + Some(r#"{"priority":7,"expected_revision":0}"#) + ), + async { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let waiting: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname = current_database() AND wait_event = 'advisory')") + .fetch_one(&f.pool).await.expect("observe commit gate"); + if waiting { break; } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("UPDATE reaches gate before any invalidation"); + expect_no_notification(&mut owner).await; + let before = f + .state + .db + .get_task(f.community, f.task_id) + .await + .expect("old committed row"); + assert_eq!(before.revision, 0); + sqlx::query("SELECT pg_advisory_unlock($1)") + .bind(gate) + .execute(&mut *lock) + .await + .expect("release commit"); + expect_sync(&mut owner, f.private_channel_id).await; + let persisted = f + .state + .db + .get_task(f.community, f.task_id) + .await + .expect("committed row"); + assert_eq!((persisted.revision, persisted.priority), (1, 7)); + } + ); + let (status, updated) = response; + assert_eq!(status, StatusCode::OK, "{updated}"); + assert_eq!(updated["revision"], 1); + sqlx::raw_sql("DROP TRIGGER task_notification_commit_gate ON tasks; DROP FUNCTION task_notification_commit_gate()") + .execute(&f.pool).await.expect("remove gate"); + for socket in [&mut outsider, &mut unauthenticated, &mut foreign] { + expect_no_notification(socket).await; + } + + let (status, _) = f + .request( + "PATCH", + &path, + &f.owner, + Some(r#"{"priority":9,"expected_revision":0}"#), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + expect_no_notification(&mut owner).await; + let (status, _) = f + .request( + "PATCH", + &path, + &f.owner, + Some(r#"{"priority":7,"expected_revision":1}"#), + ) + .await; + assert_eq!(status, StatusCode::OK); + expect_no_notification(&mut owner).await; + + let body = + json!({"title":"created after commit", "channel_id":f.private_channel_id}).to_string(); + let (status, created) = f.request("POST", "/api/tasks", &f.owner, Some(&body)).await; + assert_eq!(status, StatusCode::OK, "{created}"); + expect_sync(&mut owner, f.private_channel_id).await; + let event_path = format!("{path}/events"); + let (status, event) = f + .request( + "POST", + &event_path, + &f.owner, + Some(r#"{"body":"committed comment"}"#), + ) + .await; + assert_eq!(status, StatusCode::OK, "{event}"); + expect_sync(&mut owner, f.private_channel_id).await; + let events = f + .state + .db + .list_task_events(f.community, f.task_id) + .await + .expect("durable events"); + assert!(events + .iter() + .any(|e| e.body.as_deref() == Some("committed comment"))); + for socket in [&mut outsider, &mut unauthenticated, &mut foreign] { + expect_no_notification(socket).await; + } + + // A disconnected client recovers from an authorized GET after NIP-42 reconnect. + owner.close(None).await.expect("close owner"); + drop(owner); + let (status, _) = f + .request( + "PATCH", + &path, + &f.owner, + Some(r#"{"priority":8,"expected_revision":1}"#), + ) + .await; + assert_eq!(status, StatusCode::OK); + let mut reconnected = connect(base, &f.host, Some(&f.owner)).await; + let (status, recovered) = f.request("GET", &path, &f.owner, None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(recovered["task"]["revision"], 2); + assert_eq!(recovered["task"]["priority"], 8); + reconnected.close(None).await.expect("close"); + } + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn task_control_queue_coalesces_duplicates_and_recovers_scoped_overflow() { + let mut f = fixture().await.expect("Postgres and Redis fixture"); + let other = fixture().await.expect("foreign community"); + let _server = serve(&mut f).await; + let base = f.http_base.as_deref().expect("base"); + let mut owner = connect(base, &f.host, Some(&f.owner)).await; + let mut foreign = connect(base, &other.host, Some(&other.owner)).await; + let mut lock = f.pool.begin().await.expect("gate transaction"); + let gate_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *lock) + .await + .expect("gate pid"); + sqlx::query("LOCK TABLE channel_members IN ACCESS EXCLUSIVE MODE") + .execute(&mut *lock) + .await + .expect("hold authorization"); + let (tx, rx) = tokio::sync::broadcast::channel(1024); + let _consumer = Server(tokio::spawn(f.state.clone().run_connection_control(rx))); + let command = |channel| buzz_pubsub::conn_control::ScopedConnControl { + community_id: f.community, + command: buzz_pubsub::conn_control::ConnControl::InvalidateTasks { + channel_id: Some(channel), + origin_generation: uuid::Uuid::new_v4(), + }, + }; + tx.send(command(f.private_channel_id)) + .expect("active advisory"); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE $1 = ANY(pg_blocking_pids(pid)))") + .bind(gate_pid).fetch_one(&f.pool).await.expect("observe blocked authorization"); + if waiting { break; } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("actual fanout is held by database lock"); + for _ in 0..300 { + tx.send(command(f.private_channel_id)) + .expect("duplicate advisory"); + } + tokio::time::timeout(Duration::from_secs(1), async { + while !tx.is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .expect("production consumer consumed duplicate burst"); + expect_no_notification(&mut owner).await; + for _ in 0..257 { + tx.send(command(uuid::Uuid::new_v4())) + .expect("distinct scope advisory"); + } + tokio::time::timeout(Duration::from_secs(1), async { + loop { + match owner.next().await { + None | Some(Ok(Message::Close(_))) => break, + Some(Ok(Message::Ping(data))) => { + owner.send(Message::Pong(data)).await.expect("pong") + } + frame => panic!("unexpected overflow recovery frame: {frame:?}"), + } + } + }) + .await + .expect("bounded task queue overflow forces affected socket recovery"); + expect_no_notification(&mut foreign).await; + lock.rollback().await.expect("release authorization gate"); + } + + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn lost_control_commands_force_socket_reauthorization() { + let mut f = fixture().await.expect("Postgres and Redis fixture"); + let _server = serve(&mut f).await; + let mut owner = connect( + f.http_base.as_deref().expect("base"), + &f.host, + Some(&f.owner), + ) + .await; + // A deliberately undersized receiver deterministically loses a control + // command before the actual production consumer begins reading. + let (tx, rx) = tokio::sync::broadcast::channel(2); + for _ in 0..3 { + tx.send(buzz_pubsub::conn_control::ScopedConnControl { + community_id: f.community, + command: buzz_pubsub::conn_control::ConnControl::InvalidateTasks { + channel_id: None, + origin_generation: f.state.task_invalidation_generation, + }, + }) + .expect("receiver retained"); + } + let consumer = Server(tokio::spawn(f.state.clone().run_connection_control(rx))); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + match owner.next().await { + None | Some(Ok(Message::Close(_))) => break, + Some(Ok(Message::Ping(data))) => { + owner.send(Message::Pong(data)).await.expect("pong") + } + frame => panic!("unexpected recovery frame: {frame:?}"), + } + } + }) + .await + .expect("loss of control commands must force fresh authorization"); + drop(consumer); + drop(tx); + } + + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn urgent_disconnect_does_not_wait_for_task_authorization() { + let mut f = fixture().await.expect("Postgres and Redis fixture"); + let _server = serve(&mut f).await; + let _control = start_conn_control(f.state.clone()).await; + let mut owner = connect( + f.http_base.as_deref().expect("base"), + &f.host, + Some(&f.owner), + ) + .await; + let mut lock = f.pool.begin().await.expect("gate transaction"); + let gate_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *lock) + .await + .expect("gate pid"); + sqlx::query("LOCK TABLE channel_members IN ACCESS EXCLUSIVE MODE") + .execute(&mut *lock) + .await + .expect("hold advisory authorization"); + let ctx = buzz_core::TenantContext::resolved(f.community, &f.host); + f.state + .pubsub + .publish_conn_control( + &ctx, + &buzz_pubsub::conn_control::ConnControl::InvalidateTasks { + channel_id: Some(f.private_channel_id), + origin_generation: uuid::Uuid::new_v4(), + }, + ) + .await + .expect("publish task advisory through actual Redis"); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE $1 = ANY(pg_blocking_pids(pid)))") + .bind(gate_pid).fetch_one(&f.pool).await.expect("observe blocked permission query"); + if waiting { break; } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("production consumer entered blocked authorization"); + f.state + .pubsub + .publish_conn_control( + &ctx, + &buzz_pubsub::conn_control::ConnControl::DisconnectCommunity, + ) + .await + .expect("publish urgent disconnect through actual Redis"); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + match owner.next().await { + None | Some(Ok(Message::Close(_))) => break, + Some(Ok(Message::Ping(data))) => { + owner.send(Message::Pong(data)).await.expect("pong") + } + frame => panic!("unexpected frame while awaiting disconnect: {frame:?}"), + } + } + }) + .await + .expect("urgent disconnect must not await the five-second task permission deadline"); + lock.rollback().await.expect("release authorization gate"); + } + + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn task_notification_access_deadline_preserves_committed_http_success() { + let mut f = fixture().await.expect("Postgres and Redis fixture"); + let _server = serve(&mut f).await; + let mut owner = connect( + f.http_base.as_deref().expect("base"), + &f.host, + Some(&f.owner), + ) + .await; + // The existing HTTP gate has a valid cached allow; only the new fresh + // notification lookup is held behind this real database table lock. + f.state.accessible_channels_cache.insert( + (f.community, f.owner.public_key().to_bytes().to_vec()), + vec![f.private_channel_id], + ); + let mut lock = f.pool.begin().await.expect("gate transaction"); + sqlx::query("LOCK TABLE channel_members IN ACCESS EXCLUSIVE MODE") + .execute(&mut *lock) + .await + .expect("block notification authorization"); + let start = std::time::Instant::now(); + let (status, updated) = tokio::time::timeout( + Duration::from_secs(8), + f.request( + "PATCH", + &format!("/api/tasks/{}", f.task_id), + &f.owner, + Some(r#"{"priority":11,"expected_revision":0}"#), + ), + ) + .await + .expect("notification deadline bounds the successful HTTP response"); + assert!( + start.elapsed() >= Duration::from_secs(5), + "actual access lookup reached its deadline" + ); + assert_eq!(status, StatusCode::OK, "{updated}"); + assert_eq!(updated["revision"], 1); + lock.rollback().await.expect("release authorization gate"); + let persisted = f + .state + .db + .get_task(f.community, f.task_id) + .await + .expect("committed task"); + assert_eq!((persisted.revision, persisted.priority), (1, 11)); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + match owner.next().await { + None | Some(Ok(Message::Close(_))) => break, + Some(Ok(Message::Ping(data))) => { + owner.send(Message::Pong(data)).await.expect("pong") + } + frame => panic!("unexpected frame while awaiting recovery: {frame:?}"), + } + } + }) + .await + .expect("lost task invalidation must force client recovery"); + let mut recovered = connect( + f.http_base.as_deref().expect("base"), + &f.host, + Some(&f.owner), + ) + .await; + let (status, latest) = f + .request("GET", &format!("/api/tasks/{}", f.task_id), &f.owner, None) + .await; + assert_eq!(status, StatusCode::OK, "{latest}"); + assert_eq!(latest["task"]["revision"], 1); + assert_eq!(latest["task"]["priority"], 11); + recovered.close(None).await.expect("close recovered socket"); + } +} diff --git a/crates/buzz-relay/src/api/tasks/tests.rs b/crates/buzz-relay/src/api/tasks/tests.rs new file mode 100644 index 00000000000..6cfa2c2834a --- /dev/null +++ b/crates/buzz-relay/src/api/tasks/tests.rs @@ -0,0 +1,753 @@ +use super::*; +use axum::http::Uri; + +#[test] +fn request_path_preserves_signed_query_verbatim() { + assert_eq!( + request_path("/api/tasks", Some("status=todo&limit=10")), + "/api/tasks?status=todo&limit=10" + ); + assert_eq!(request_path("/api/tasks", None), "/api/tasks"); + assert_eq!(request_path("/api/tasks", Some("")), "/api/tasks"); +} + +#[test] +fn source_ref_survives_verbatim_into_the_signed_path() { + // The client signs the raw query, so the relay must reconstruct it + // byte-for-byte. A normalised or re-ordered `source_ref` would break + // the NIP-98 signature rather than merely filter differently. + let raw = "channel=6f1b0e2c-0000-4000-8000-000000000001&source_ref=abc123"; + assert_eq!( + request_path("/api/tasks", Some(raw)), + format!("/api/tasks?{raw}") + ); +} + +#[test] +fn source_ref_is_parsed_as_an_opaque_optional_string() { + // Opaque TEXT by design (migrations/0046_task_system.sql): the relay + // must not validate it as an event id, and its absence must stay + // distinct from a present value. + fn parse(query: &str) -> TasksQuery { + let uri: Uri = format!("http://relay.invalid/api/tasks?{query}") + .parse() + .expect("valid uri"); + Query::::try_from_uri(&uri).expect("parses").0 + } + + assert_eq!(parse("status=todo").source_ref, None); + assert_eq!( + parse("source_ref=not-an-event-id").source_ref.as_deref(), + Some("not-an-event-id") + ); +} + +#[test] +fn title_length_is_counted_in_characters_not_bytes() { + // 200 multi-byte characters is 600 bytes but a legal title; counting + // bytes here would 400 a request the database would have accepted. + let multibyte = "é".repeat(200); + assert_eq!( + validate_title(&multibyte).expect("200 chars is legal"), + multibyte + ); + assert!(validate_title(&"é".repeat(201)).is_err()); +} + +#[test] +fn title_is_trimmed_and_must_not_be_blank() { + assert_eq!(validate_title(" ship it ").expect("trims"), "ship it"); + assert!(validate_title(" ").is_err()); + assert!(validate_title("").is_err()); +} + +#[test] +fn assignee_must_be_a_32_byte_hex_pubkey() { + let valid = "ab".repeat(32); + assert_eq!( + parse_pubkey("assignee", &valid).expect("valid"), + vec![0xab; 32] + ); + assert!(parse_pubkey("assignee", "not-hex").is_err()); + assert!(parse_pubkey("assignee", &"ab".repeat(31)).is_err()); + assert!(parse_pubkey("assignee", &"ab".repeat(33)).is_err()); +} + +#[test] +fn absent_and_null_assignee_are_different_patches() { + // The whole point of the double option: `{}` leaves the assignee + // alone, `{"assignee": null}` unassigns. + let absent: UpdateTaskRequest = serde_json::from_str("{}").expect("absent"); + assert_eq!(absent.assignee, None); + + let cleared: UpdateTaskRequest = serde_json::from_str(r#"{"assignee": null}"#).expect("null"); + assert_eq!(cleared.assignee, Some(None)); + + let set: UpdateTaskRequest = serde_json::from_str(r#"{"assignee": "abc"}"#).expect("set"); + assert_eq!(set.assignee, Some(Some("abc".to_owned()))); +} + +#[test] +fn absent_and_null_due_at_are_different_patches() { + let absent: UpdateTaskRequest = serde_json::from_str("{}").expect("absent"); + assert_eq!(absent.due_at, None); + + let cleared: UpdateTaskRequest = serde_json::from_str(r#"{"due_at": null}"#).expect("null"); + assert_eq!(cleared.due_at, Some(None)); +} + +#[test] +fn task_wire_renders_status_and_hex_pubkeys() { + let task = TaskRecord { + id: Uuid::nil(), + channel_id: None, + created_by_pubkey: Some(vec![0xab; 32]), + assignee_pubkey: None, + parent_task_id: None, + title: "ship it".to_owned(), + body: None, + status: TaskStatus::InProgress, + priority: 3, + source: Some("claude".to_owned()), + source_ref: None, + due_at: None, + done_at: None, + archived_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + revision: 0, + }; + let wire = task_json(&task); + assert_eq!(wire["status"], "in_progress"); + assert_eq!(wire["created_by"], hex::encode([0xab; 32])); + assert!(wire["assignee"].is_null()); + assert_eq!(wire["priority"], 3); + // Raw bytes must never reach the wire. + assert!(wire.get("created_by_pubkey").is_none()); +} + +#[test] +fn task_event_wire_renders_both_status_ends() { + let event = TaskEventRecord { + id: 7, + task_id: Uuid::nil(), + actor_pubkey: None, + action: TaskAction::StatusChanged, + changes: None, + from_status: Some(TaskStatus::Todo), + to_status: Some(TaskStatus::Done), + body: None, + created_at: Utc::now(), + }; + let wire = task_event_json(&event); + assert_eq!(wire["action"], "status_changed"); + assert_eq!(wire["from_status"], "todo"); + assert_eq!(wire["to_status"], "done"); +} + +/// Route-level private-channel authorization (COMPAT LANE 3, §7 closure). +/// +/// Drives the REAL router (`build_router` + `oneshot`) with REAL NIP-98 +/// auth headers against a REAL Postgres community containing a private +/// channel and a channel-bound task. Proves at the route seam — not the +/// db seam — that a relay member who is NOT a channel member: +/// * gets 404 (never 403, never the task) on GET/PATCH/POST-events, +/// * gets the task silently filtered out of a channel list, and +/// * cannot even create a task bound to the private channel. +/// +/// The relay-membership gate is exercised with `require_relay_membership +/// = true` so the 404s below are authz verdicts, not gate bypasses. +/// +/// Postgres + Redis are required: run with +/// `cargo test -p buzz-relay --lib api::tasks -- --ignored`. +mod route_authz { + use super::super::*; + use crate::state::AppState; + use buzz_core::channel::{ChannelType, ChannelVisibility}; + use buzz_db::task::NewTask; + use nostr::Keys; + use sha2::{Digest, Sha256}; + + use axum::body::{to_bytes, Body}; + use axum::http::{header, Request, StatusCode}; + use tower::ServiceExt; + + const TEST_DB_URL: &str = "postgres://buzz:***@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + /// Same trick as the invites tests: the shared AlwaysFreshReplayGuard + /// is gated behind buzz-auth/test-utils, which this crate doesn't + /// enable, so define the pass-through locally. + struct AlwaysFreshReplayGuard; + + impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async { Ok(true) }) + } + } + + /// Clone of the invites.rs NIP-98 helper: signs kind:27235 over the + /// exact URL the relay will reconstruct (scheme from config.relay_url, + /// host from the tenant, path + raw query). + pub(super) fn nip98_auth_header(keys: &Keys, method: &str, url: &str, body: &[u8]) -> String { + let hash: [u8; 32] = Sha256::digest(body).into(); + let tags = vec![ + nostr::Tag::parse(["u", url]).expect("u tag"), + nostr::Tag::parse(["method", method]).expect("method tag"), + nostr::Tag::parse(["payload", hex::encode(hash).as_str()]).expect("payload tag"), + // Each helper call is a fresh HTTP request, even when its signed + // stored-event body repeats in the same second. + nostr::Tag::parse(["nonce", &Uuid::new_v4().to_string()]).expect("nonce tag"), + ]; + let event = nostr::EventBuilder::new(nostr::Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); + let encoded = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, event_json); + format!("Nostr {encoded}") + } + + #[allow(dead_code)] // AGENT-HOMES-001: shared fixture; fields used by sibling test mods + pub(super) struct Fixture { + pub(super) state: Arc, + #[allow(dead_code)] + pub(super) pool: sqlx::PgPool, + pub(super) host: String, + pub(super) community: buzz_core::CommunityId, + pub(super) private_channel_id: Uuid, + pub(super) task_id: Uuid, + pub(super) owner: Keys, + pub(super) outsider: Keys, + pub(super) http_base: Option, + } + + /// Boot an AppState bound to a fresh community whose Postgres + Redis + /// are live. Redis must be real: the HTTP admission gate fails closed + /// (503) when the shared limiter is unavailable, which would mask the + /// authorization verdict under test. + pub(super) async fn fixture() -> Option { + let host = format!("task-authz-{}.example", Uuid::new_v4().simple()); + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + let redis_url = std::env::var("BUZZ_TEST_REDIS_URL") + .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = database_url.clone(); + config.redis_url = redis_url.clone(); + config.relay_url = format!("wss://{host}"); + config.require_relay_membership = true; + config.require_auth_token = false; + + let pool = sqlx::PgPool::connect(&database_url).await.ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let ensured = db.ensure_configured_community(&host).await.ok()?; + + // Live Redis pool for admission + pubsub, mirroring invite tests. + let redis_pool = deadpool_redis::Config::from_url(&redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&redis_url, redis_pool.clone()) + .await + .ok()?, + ); + + let owner = Keys::generate(); + let outsider = Keys::generate(); + let owner_pk = owner.public_key().to_bytes().to_vec(); + let outsider_pk = outsider.public_key().to_bytes().to_vec(); + + // Both are relay members (the outer gate) so every response below + // isolates the CHANNEL gate, not relay membership. + buzz_db::user::ensure_user(&pool, ensured.id, &owner_pk) + .await + .ok()?; + buzz_db::user::ensure_user(&pool, ensured.id, &outsider_pk) + .await + .ok()?; + db.add_relay_member(ensured.id, &owner.public_key().to_hex(), "member", None) + .await + .ok()?; + db.add_relay_member(ensured.id, &outsider.public_key().to_hex(), "member", None) + .await + .ok()?; + + // Private channel owned by `owner` — outsider is not a member. + let channel = buzz_db::channel::create_channel( + &pool, + ensured.id, + "task-authz-private", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &owner_pk, + None, + ) + .await + .ok()?; + + // A task bound to that private channel. + let task = db + .create_task( + ensured.id, + NewTask { + channel_id: Some(channel.id), + created_by_pubkey: Some(owner_pk.clone()), + title: "route authz probe".to_owned(), + ..NewTask::default() + }, + ) + .await + .ok()?; + + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + let (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + let state = Arc::new(state); + + Some(Fixture { + state, + pool, + host, + community: ensured.id, + private_channel_id: channel.id, + task_id: task.id, + owner, + outsider, + http_base: None, + }) + } + + #[allow(dead_code)] // AGENT-HOMES-001: retained for future integration tests + async fn cleanup(f: &Fixture) { + for table in ["task_events", "tasks", "channel_members", "channels"] { + let sql = format!("DELETE FROM {table} WHERE community_id = $1"); + sqlx::query(sqlx::AssertSqlSafe(sql)) + .bind(f.community.as_uuid()) + .execute(&f.pool) + .await + .expect("cleanup channel/task rows"); + } + let _ = f + .state + .db + .remove_relay_member(f.community, &f.outsider.public_key().to_hex()) + .await; + let _ = f + .state + .db + .remove_relay_member(f.community, &f.owner.public_key().to_hex()) + .await; + sqlx::query("DELETE FROM users WHERE community_id = $1") + .bind(f.community.as_uuid()) + .execute(&f.pool) + .await + .expect("cleanup users"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(f.community.as_uuid()) + .execute(&f.pool) + .await + .expect("cleanup community"); + } + + impl Fixture { + pub(super) fn router(&self) -> axum::Router { + crate::router::build_router(self.state.clone()) + } + + pub(super) async fn request( + &self, + method: &str, + path_and_query: &str, + keys: &Keys, + body: Option<&str>, + ) -> (StatusCode, serde_json::Value) { + let url = format!("https://{}{}", self.host, path_and_query); + let body_bytes = body.map(str::as_bytes).unwrap_or_default(); + let auth = nip98_auth_header(keys, method, &url, body_bytes); + if let Some(base) = &self.http_base { + let client = reqwest::Client::new(); + let response = client + .request( + method.parse().expect("method"), + format!("{base}{path_and_query}"), + ) + .header(header::HOST, &self.host) + .header(header::AUTHORIZATION, auth) + .header(header::CONTENT_TYPE, "application/json") + .body(body_bytes.to_vec()) + .send() + .await + .expect("HTTP response"); + let status = response.status(); + let json = response.json().await.expect("HTTP JSON response"); + return (status, json); + } + let mut builder = Request::builder() + .method(method) + .uri(path_and_query) + .header(header::HOST, &self.host) + .header(header::AUTHORIZATION, auth); + if body.is_some() { + builder = builder.header(header::CONTENT_TYPE, "application/json"); + } + let response = crate::router::build_router(self.state.clone()) + .oneshot( + builder + .body(Body::from(body_bytes.to_vec())) + .expect("request"), + ) + .await + .expect("response"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("read body"); + let json: serde_json::Value = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null) + }; + (status, json) + } + } + + pub(super) async fn pagination_and_history_assertions(f: &Fixture) { + let mut ids = Vec::new(); + // Insert equal subsecond timestamps directly. UPDATE timestamps are + // derived by the revision trigger and cannot serve as a fixture setter. + for title in ["visible one", "visible two", "visible three"] { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO tasks (community_id, id, title, updated_at) VALUES ($1, $2, $3, '2026-01-01T00:00:00.123456Z')") + .bind(f.community.as_uuid()).bind(id).bind(title) + .execute(&f.pool).await.expect("visible fixture task"); + sqlx::query("INSERT INTO task_events (community_id, task_id, action) VALUES ($1, $2, 'created')") + .bind(f.community.as_uuid()).bind(id).execute(&f.pool).await.expect("fixture creation history"); + ids.push(id.to_string()); + } + ids.sort_by(|a, b| b.cmp(a)); + let (status, first) = f + .request("GET", "/api/tasks?limit=2", &f.outsider, None) + .await; + assert_eq!(status, StatusCode::OK, "first page: {first}"); + let rows = first["tasks"].as_array().expect("tasks array"); + assert_eq!( + rows.len(), + 2, + "invisible newer tasks must not consume the limit" + ); + assert_eq!(rows[0]["id"], ids[0]); + assert_eq!(rows[1]["id"], ids[1]); + let cursor = first["next_cursor"].as_str().expect("next cursor"); + let path = format!("/api/tasks?limit=2&before={cursor}"); + let (status, second) = f.request("GET", &path, &f.outsider, None).await; + assert_eq!(status, StatusCode::OK, "second page: {second}"); + assert_eq!(second["tasks"].as_array().expect("second tasks").len(), 1); + assert_eq!(second["tasks"][0]["id"], ids[2]); + assert!(second["next_cursor"].is_null()); + let (status, _) = f + .request("GET", "/api/tasks?before=invalid", &f.owner, None) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + let task_path = format!("/api/tasks/{}", ids[0]); + let payload = serde_json::json!({ + "assignee": f.owner.public_key().to_hex(), "priority": 4, + "due_at": "2026-09-10T11:12:13.123456Z", + }) + .to_string(); + let (status, updated) = f + .request("PATCH", &task_path, &f.owner, Some(&payload)) + .await; + assert_eq!(status, StatusCode::OK, "patch: {updated}"); + assert_eq!(updated["priority"], 4); + let (status, detail) = f.request("GET", &task_path, &f.owner, None).await; + assert_eq!(status, StatusCode::OK, "detail: {detail}"); + let events = detail["events"].as_array().expect("event history"); + assert_eq!(events.len(), 4); + for (action, expected) in [ + ( + "assigned", + serde_json::json!({"assignee": {"from": null, "to": f.owner.public_key().to_hex()}}), + ), + ( + "priority_changed", + serde_json::json!({"priority": {"from": 0, "to": 4}}), + ), + ( + "due_at_changed", + serde_json::json!({"due_at": {"from": null, "to": "2026-09-10T11:12:13.123456Z"}}), + ), + ] { + let event = events + .iter() + .find(|event| event["action"] == action) + .expect("change event"); + assert_eq!(event["changes"], expected); + assert_eq!(event["actor"], f.owner.public_key().to_hex()); + } + let (status, _) = f + .request("PATCH", &task_path, &f.owner, Some(&payload)) + .await; + assert_eq!(status, StatusCode::OK); + let (_, retry) = f.request("GET", &task_path, &f.owner, None).await; + assert_eq!( + retry["events"], detail["events"], + "signed retry must not append duplicate history" + ); + // Exercise actual signed creation and guarded updates over this HTTP + // listener in addition to the deliberately tied pagination fixtures. + let (status, created) = f + .request( + "POST", + "/api/tasks", + &f.owner, + Some(r#"{"title":"guarded task"}"#), + ) + .await; + assert_eq!(status, StatusCode::OK, "create: {created}"); + assert_eq!(created["revision"], 0); + let guarded_path = format!("/api/tasks/{}", created["id"].as_str().expect("id")); + let (status, changed) = f + .request( + "PATCH", + &guarded_path, + &f.owner, + Some(r#"{"priority":7,"expected_revision":0}"#), + ) + .await; + assert_eq!(status, StatusCode::OK, "guarded change: {changed}"); + assert_eq!(changed["revision"], 1); + let (_, before_conflict) = f.request("GET", &guarded_path, &f.owner, None).await; + assert_eq!( + before_conflict["events"].as_array().expect("history").len(), + 2 + ); + let (status, conflict) = f + .request( + "PATCH", + &guarded_path, + &f.owner, + Some(r#"{"priority":9,"expected_revision":0}"#), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "stale update: {conflict}"); + assert!(conflict["error"] + .as_str() + .expect("error") + .contains("actual 1")); + let (_, after_conflict) = f.request("GET", &guarded_path, &f.owner, None).await; + assert_eq!( + after_conflict, before_conflict, + "a 409 cannot mutate task or history" + ); + let (status, no_op) = f + .request( + "PATCH", + &guarded_path, + &f.owner, + Some(r#"{"priority":7,"expected_revision":1}"#), + ) + .await; + assert_eq!(status, StatusCode::OK, "no-op: {no_op}"); + assert_eq!(no_op, changed); + let (status, _) = f + .request( + "PATCH", + &guarded_path, + &f.owner, + Some(r#"{"expected_revision":1}"#), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + eprintln!("PASS signed HTTP create/update/history; private pagination 2+1; revision 0→1; stale write 409; guarded no-op unchanged; guard-only 400"); + } + + pub(super) async fn private_channel_assertions(f: &Fixture) { + let task_path = format!("/api/tasks/{}", f.task_id); + + // --- Positive control: the owner sees the task. Without this, 404s + // for the outsider could be any breakage at all. + let (status, body) = f.request("GET", &task_path, &f.owner, None).await; + assert_eq!( + status, + StatusCode::OK, + "owner must see the task; got {status} {body}" + ); + assert_eq!(body["task"]["title"], "route authz probe"); + + // --- GET detail as outsider: 404, never 403, never the task. + let (status, body) = f.request("GET", &task_path, &f.outsider, None).await; + assert_eq!(status, StatusCode::NOT_FOUND, "got {status} {body}"); + assert_eq!(body["error"], "task not found"); + + // --- PATCH as outsider: 404 too. + let (status, body) = f + .request( + "PATCH", + &task_path, + &f.outsider, + Some(r#"{"status":"done"}"#), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "got {status} {body}"); + + // --- POST comment as outsider: 404. + let (status, body) = f + .request( + "POST", + &format!("{task_path}/events"), + &f.outsider, + Some(r#"{"action":"commented","body":"leak?"}"#), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "got {status} {body}"); + + // --- Channel-filtered list as outsider: 404, not 200-with-empty. + // The explicit channel filter is itself gated by + // enforce_channel_access (list_tasks), so an outsider cannot even + // probe whether a channel exists — same anti-oracle rule as the + // detail routes. The invisible-channel task is simply unreadable. + let list_path = format!("/api/tasks?channel={}", f.private_channel_id); + let (status, body) = f.request("GET", &list_path, &f.outsider, None).await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "channel filter must 404 for an invisible channel; got {status} {body}" + ); + + // --- Unfiltered list as outsider: the task must also vanish. + let (status, body) = f.request("GET", "/api/tasks", &f.outsider, None).await; + assert_eq!(status, StatusCode::OK); + let titles: Vec<&str> = body["tasks"] + .as_array() + .map(|tasks| tasks.iter().filter_map(|t| t["title"].as_str()).collect()) + .unwrap_or_default(); + assert!( + !titles.contains(&"route authz probe"), + "task leaked into unfiltered list" + ); + + // --- The owner's list DOES contain it (control for both lists). + let (_, body) = f.request("GET", "/api/tasks", &f.owner, None).await; + let titles: Vec<&str> = body["tasks"] + .as_array() + .map(|tasks| tasks.iter().filter_map(|t| t["title"].as_str()).collect()) + .unwrap_or_default(); + assert!( + titles.contains(&"route authz probe"), + "owner must see the task in the unfiltered list" + ); + + // --- Create bound to the private channel as outsider: 404. + let (status, body) = f + .request( + "POST", + "/api/tasks", + &f.outsider, + Some( + &serde_json::json!({ + "title": "should not exist", + "channel_id": f.private_channel_id, + }) + .to_string(), + ), + ) + .await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "create must not bind to an invisible channel; got {status} {body}" + ); + } +} + +mod postgres_tests { + use super::route_authz::{ + fixture, pagination_and_history_assertions, private_channel_assertions, + }; + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn signed_task_routes_page_visible_work_and_expose_durable_changes() { + let mut f = fixture() + .await + .expect("Postgres and Redis fixture must be available"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("test HTTP listener"); + f.http_base = Some(format!( + "http://{}", + listener.local_addr().expect("bound address") + )); + let router = f.router(); + struct Server(tokio::task::JoinHandle<()>); + impl Drop for Server { + fn drop(&mut self) { + self.0.abort(); + } + } + let _server = Server(tokio::spawn(async move { + axum::serve(listener, router).await.expect("HTTP server"); + })); + pagination_and_history_assertions(&f).await; + } + + /// The single route-level scenario: a relay member outside a private + /// channel must receive 404 (not 403, not data) on every task route, + /// and the channel-bound task must vanish from listings. The owner's + /// positive control proves the 404s are authz, not breakage. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn private_channel_task_is_invisible_to_non_members_at_the_route() { + let f = fixture() + .await + .expect("Postgres and Redis fixture must be available"); + // Catch assertion panics so cleanup ALWAYS runs, then resume them. + let result = futures::FutureExt::catch_unwind(std::panic::AssertUnwindSafe( + private_channel_assertions(&f), + )) + .await; + drop(f); + if let Err(payload) = result { + std::panic::resume_unwind(payload); + } + } +} + +#[path = "notifications_tests.rs"] +mod task_notifications; + +#[path = "fleet_tests.rs"] +mod fleet_admission; + +#[path = "machine_tests.rs"] +mod machine_control; diff --git a/crates/buzz-relay/src/api/workflow_approval_postgres_tests.rs b/crates/buzz-relay/src/api/workflow_approval_postgres_tests.rs new file mode 100644 index 00000000000..60aab0b9d89 --- /dev/null +++ b/crates/buzz-relay/src/api/workflow_approval_postgres_tests.rs @@ -0,0 +1,635 @@ +//! Real signed HTTP workflow approvals against PostgreSQL, Redis and the native router. +use crate::state::AppState; +use base64::Engine; +use buzz_core::{ + channel::{ChannelType, ChannelVisibility, MemberRole}, + CommunityId, +}; +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use uuid::Uuid; + +struct Fixture { + state: Arc, + pool: sqlx::PgPool, + host: String, + community: CommunityId, + channel: Uuid, + workflow: Uuid, + owner: Keys, + outsider: Keys, + address: std::net::SocketAddr, + server: tokio::task::JoinHandle<()>, +} +impl Drop for Fixture { + fn drop(&mut self) { + self.server.abort(); + } +} + +async fn fixture(timeout: &str, from_any: bool) -> Fixture { + let host = format!("workflow-http-{}.example", Uuid::new_v4()); + let mut config = crate::config::Config::from_env().expect("config"); + config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .expect("isolated test DB URL"); + config.redis_url = std::env::var("BUZZ_TEST_REDIS_URL").expect("isolated real Redis URL"); + config.relay_url = format!("ws://{host}"); + config.require_relay_membership = true; + config.require_auth_token = false; + let pool = sqlx::PgPool::connect(&config.database_url) + .await + .expect("Postgres"); + let db = buzz_db::Db::from_pool(pool.clone()); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrations"); + } + let community = db + .ensure_configured_community(&host) + .await + .expect("community") + .id; + let owner = Keys::generate(); + let outsider = Keys::generate(); + for key in [&owner, &outsider] { + db.ensure_user(community, &key.public_key().to_bytes()) + .await + .expect("user"); + db.add_relay_member(community, &key.public_key().to_hex(), "member", None) + .await + .expect("relay membership"); + } + let channel = db + .create_channel( + community, + "approval-http", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &owner.public_key().to_bytes(), + None, + ) + .await + .expect("channel") + .id; + let definition = json!({"name":"approval-http","trigger":{"on":"webhook"},"enabled":true, + "steps":[{"id":"before","action":"delay","duration":"0s"}, + {"id":"review","action":"request_approval","from":if from_any {"any".into()}else{owner.public_key().to_hex()},"message":"Approve {{trigger.request}}","timeout":timeout}, + {"id":"after","action":"send_message","text":"Approved {{trigger.request}} with prior {{steps.before.output.slept_secs}} and decision {{steps.review.output.approved}}"}]}); + let hash = Sha256::digest(definition.to_string().as_bytes()); + let workflow = db + .create_workflow( + community, + Some(channel), + &owner.public_key().to_bytes(), + "approval-http", + &definition.to_string(), + &hash, + ) + .await + .expect("workflow"); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("Redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media = buzz_media::MediaStorage::new(&config.media).expect("media"); + let (state, _) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + engine, + Keys::generate(), + media, + ); + let state = Arc::new(state); + state + .workflow_engine + .set_action_sink(Arc::new(crate::workflow_sink::RelayActionSink::new(&state))); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("TCP listener"); + let address = listener.local_addr().expect("address"); + let router = crate::router::build_router(state.clone()); + let server = tokio::spawn(async move { + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await + .expect("HTTP server"); + }); + Fixture { + state, + pool, + host, + community, + channel, + workflow, + owner, + outsider, + address, + server, + } +} + +impl Fixture { + async fn request( + &self, + keys: &Keys, + method: &str, + path: &str, + body: Option, + ) -> (u16, Value) { + let body = body + .map(|v| serde_json::to_vec(&v).expect("JSON")) + .unwrap_or_default(); + let signed_url = format!("http://{}{path}", self.host); + let auth = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", &signed_url]).expect("u"), + Tag::parse(["method", method]).expect("method"), + Tag::parse(["payload", &hex::encode(Sha256::digest(&body))]).expect("payload"), + Tag::parse(["nonce", &Uuid::new_v4().to_string()]).expect("nonce"), + ]) + .sign_with_keys(keys) + .expect("signed NIP-98"); + let response = reqwest::Client::new() + .request( + method.parse().expect("method"), + format!("http://{}{path}", self.address), + ) + .header("Host", &self.host) + .header( + "Authorization", + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&auth).expect("auth JSON")) + ), + ) + .header("Content-Type", "application/json") + .body(body) + .send() + .await + .expect("real HTTP response"); + let status = response.status().as_u16(); + let body = response.text().await.expect("body"); + let value = serde_json::from_str(&body).unwrap_or_else(|_| json!({"raw":body})); + (status, value) + } + async fn submit(&self, keys: &Keys, event: &Event) -> (u16, Value) { + self.request( + keys, + "POST", + "/events", + Some(serde_json::to_value(event).expect("event JSON")), + ) + .await + } + async fn start(&self) -> (Uuid, Value) { + let event = EventBuilder::new(Kind::Custom(46020), r#"{"request":"production-test"}"#) + .tags([Tag::parse(["d", &self.workflow.to_string()]).expect("d")]) + .sign_with_keys(&self.owner) + .expect("signed trigger"); + let (status, response) = self.submit(&self.owner, &event).await; + assert_eq!(status, 200, "trigger: {response}"); + assert_eq!(response["accepted"], true, "{response}"); + let message = response["message"] + .as_str() + .expect("message") + .strip_prefix("response:") + .expect("receipt"); + let receipt: Value = serde_json::from_str(message).expect("receipt JSON"); + let run = Uuid::parse_str(receipt["run_id"].as_str().expect("run ID")).expect("UUID"); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let row = self + .state + .db + .get_workflow_run(self.community, run) + .await + .expect("run"); + if row.status == buzz_db::workflow::RunStatus::WaitingApproval { + break; + } + assert!( + !matches!( + row.status, + buzz_db::workflow::RunStatus::Failed + | buzz_db::workflow::RunStatus::Completed + ), + "unexpected run: {row:?}" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("wait persisted"); + let path = format!("/workflows/{}/runs/{run}/approvals", self.workflow); + let (status, body) = self.request(&self.owner, "GET", &path, None).await; + assert_eq!(status, 200, "approval read: {body}"); + (run, body["approvals"][0].clone()) + } + fn decision(&self, key: &Keys, approval: &Value, grant: bool, note: &str) -> Event { + EventBuilder::new(Kind::Custom(if grant { 46030 } else { 46031 }), note) + .tags([ + Tag::parse(["d", approval["approval_ref"].as_str().expect("reference")]) + .expect("d"), + ]) + .sign_with_keys(key) + .expect("signed decision") + } + fn restarted_engine(&self) -> Arc { + let engine = Arc::new(buzz_workflow::WorkflowEngine::new( + self.state.db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + engine.set_action_sink(Arc::new(crate::workflow_sink::RelayActionSink::new( + &self.state, + ))); + engine + } + async fn terminal(&self, run: Uuid) -> buzz_db::workflow::WorkflowRunRecord { + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let row = self + .state + .db + .get_workflow_run(self.community, run) + .await + .expect("run"); + if matches!( + row.status, + buzz_db::workflow::RunStatus::Completed + | buzz_db::workflow::RunStatus::Cancelled + | buzz_db::workflow::RunStatus::Failed + ) { + return row; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("terminal run") + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_approval_signed_http_restart_replay_and_exactly_one_effect() { + let f = fixture("1h", false).await; + let (run, approval) = f.start().await; + assert_eq!(approval["message"], "Approve production-test"); + let request_id = hex::decode( + approval["request_event_id"] + .as_str() + .expect("request identity"), + ) + .expect("hex"); + let request = f + .state + .db + .get_event_by_id(f.community, &request_id) + .await + .expect("lookup") + .expect("signed request"); + request.event.verify().expect("valid relay signature"); + assert!(request.event.tags.iter().any(|tag| tag.as_slice() + == [ + "workflow-revision", + approval["definition_hash"].as_str().expect("revision") + ])); + let saved: Value = sqlx::query_scalar( + "SELECT continuation FROM workflow_approvals WHERE community_id=$1 AND token=$2", + ) + .bind(f.community.as_uuid()) + .bind(hex::decode(approval["approval_ref"].as_str().expect("ref")).expect("hex")) + .fetch_one(&f.pool) + .await + .expect("saved snapshot"); + let digest = hex::encode(Sha256::digest( + serde_json::to_vec(&saved).expect("snapshot JSON"), + )); + assert!(request + .event + .tags + .iter() + .any(|tag| tag.as_slice() == ["continuation-sha256", digest.as_str()])); + assert_eq!(request.channel_id, Some(f.channel)); + let outsider = f.decision(&f.outsider, &approval, true, "unauthorized"); + let (status, body) = f.submit(&f.outsider, &outsider).await; + assert!( + status >= 400 || body["accepted"] == false, + "outsider: {body}" + ); + let grant = f.decision(&f.owner, &approval, true, "approved exact request"); + let (status, body) = f.submit(&f.owner, &grant).await; + assert_eq!(status, 200, "grant: {body}"); + assert_eq!(body["accepted"], true, "{body}"); + let (status, body) = f.submit(&f.owner, &grant).await; + assert_eq!(status, 200, "replay: {body}"); + assert!( + body["message"] + .as_str() + .expect("receipt") + .contains("\"duplicate\":true"), + "{body}" + ); + let a = f.restarted_engine(); + let b = f.restarted_engine(); + let recovering_a = tokio::spawn(async move { a.run().await }); + let recovering_b = tokio::spawn(async move { b.run().await }); + let result = f.terminal(run).await; + recovering_a.abort(); + recovering_b.abort(); + assert_eq!( + result.status, + buzz_db::workflow::RunStatus::Completed, + "{result:?}" + ); + let trace = result.execution_trace.as_array().expect("trace"); + let after = trace + .iter() + .find(|v| v["step_id"] == "after") + .expect("after step"); + let id = hex::decode(after["output"]["event_id"].as_str().expect("effect ID")).expect("hex"); + let effect = f + .state + .db + .get_event_by_id(f.community, &id) + .await + .expect("lookup") + .expect("effect"); + assert_eq!( + effect.event.content, + "Approved production-test with prior 0 and decision true" + ); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND channel_id=$2 AND kind=9", + ) + .bind(f.community.as_uuid()) + .bind(f.channel) + .fetch_one(&f.pool) + .await + .expect("effect count"); + assert_eq!(count, 1); + let deny = f.decision(&f.owner, &approval, false, "competing denial"); + let (status, body) = f.submit(&f.owner, &deny).await; + assert!( + status >= 400 || body["accepted"] == false, + "competing decision: {body}" + ); + eprintln!("WF-08 actual signed HTTP: persisted request -> grant -> exact replay -> recreated engines -> completed; one kind9 effect and preserved trigger/prior output"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_approval_signed_http_second_wait_survives_late_finalizer() { + let f = fixture("1h", false).await; + let row = f + .state + .db + .get_workflow(f.community, f.workflow) + .await + .expect("workflow"); + let mut definition = row.definition; + definition["steps"].as_array_mut().expect("steps").insert( + 2, + json!({ + "id":"review_again", "action":"request_approval", "from":f.owner.public_key().to_hex(), + "message":"Confirm {{steps.review.output.approved}}", "timeout":"1h", + }), + ); + sqlx::query( + "UPDATE workflows SET definition=$3,definition_hash=$4 WHERE community_id=$1 AND id=$2", + ) + .bind(f.community.as_uuid()) + .bind(f.workflow) + .bind(&definition) + .bind(Sha256::digest(definition.to_string().as_bytes()).as_slice()) + .execute(&f.pool) + .await + .expect("two approval definition"); + let (run, first) = f.start().await; + let grant = f.decision(&f.owner, &first, true, "first approved"); + let (status, body) = f.submit(&f.owner, &grant).await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["accepted"], true, "{body}"); + f.restarted_engine() + .recover_approvals() + .await + .expect("first restart"); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let row = f + .state + .db + .get_workflow_run(f.community, run) + .await + .expect("run"); + if row.status == buzz_db::workflow::RunStatus::WaitingApproval && row.current_step == 2 + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("second wait committed"); + let path = format!("/workflows/{}/runs/{run}/approvals", f.workflow); + let (status, body) = f.request(&f.owner, "GET", &path, None).await; + assert_eq!(status, 200, "{body}"); + let approvals = body["approvals"].as_array().expect("approvals"); + assert_eq!(approvals.len(), 2); + let second = approvals + .iter() + .find(|a| a["step_id"] == "review_again") + .expect("second wait"); + assert_eq!(second["message"], "Confirm true"); + assert_ne!(second["approval_ref"], first["approval_ref"]); + // Model an old executor returning an error after its sink committed a wait. + f.state + .workflow_engine + .finalize_run( + f.community, + run, + Err(( + buzz_workflow::WorkflowError::Database("late fanout failure".into()), + buzz_workflow::error::PartialProgress { + step_index: 2, + trace: vec![], + }, + )), + None, + ) + .await; + assert_eq!( + f.state + .db + .get_workflow_run(f.community, run) + .await + .expect("preserved wait") + .status, + buzz_db::workflow::RunStatus::WaitingApproval + ); + let grant = f.decision(&f.owner, second, true, "second approved"); + let (status, body) = f.submit(&f.owner, &grant).await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["accepted"], true, "{body}"); + f.restarted_engine() + .recover_approvals() + .await + .expect("second restart"); + assert_eq!( + f.terminal(run).await.status, + buzz_db::workflow::RunStatus::Completed + ); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND channel_id=$2 AND kind=9", + ) + .bind(f.community.as_uuid()) + .bind(f.channel) + .fetch_one(&f.pool) + .await + .expect("effects"); + assert_eq!(count, 1); + eprintln!("WF-08 signed HTTP: two distinct persisted waits and approvals across recreated engines; late finalizer preserved second wait; one final effect"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_approval_signed_http_expiration_is_durable() { + let f = fixture("1s", true).await; + let (run, approval) = f.start().await; + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + let grant = f.decision(&f.owner, &approval, true, "too late"); + let (status, body) = f.submit(&f.owner, &grant).await; + assert!( + status >= 400 || body["accepted"] == false, + "expired decision: {body}" + ); + f.restarted_engine() + .recover_approvals() + .await + .expect("recover expiry"); + assert_eq!( + f.terminal(run).await.error_code.as_deref(), + Some("approval_expired") + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_approval_signed_http_revoked_approver_cannot_resume() { + let f = fixture("1h", true).await; + f.state + .db + .add_member( + f.community, + f.channel, + &f.outsider.public_key().to_bytes(), + MemberRole::Member, + Some(&f.owner.public_key().to_bytes()), + ) + .await + .expect("approver membership"); + let (run, approval) = f.start().await; + let grant = f.decision(&f.outsider, &approval, true, "approved before revocation"); + let (status, body) = f.submit(&f.outsider, &grant).await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["accepted"], true, "{body}"); + sqlx::query("UPDATE channel_members SET removed_at=clock_timestamp() WHERE community_id=$1 AND channel_id=$2 AND pubkey=$3").bind(f.community.as_uuid()).bind(f.channel).bind(f.outsider.public_key().to_bytes().as_slice()).execute(&f.pool).await.expect("revoke fixture membership"); + f.restarted_engine() + .recover_approvals() + .await + .expect("recover"); + let run = f.terminal(run).await; + assert_eq!(run.status, buzz_db::workflow::RunStatus::Failed); + assert_eq!(run.error_code.as_deref(), Some("owner_unauthorized")); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND channel_id=$2 AND kind=9", + ) + .bind(f.community.as_uuid()) + .bind(f.channel) + .fetch_one(&f.pool) + .await + .expect("count"); + assert_eq!(count, 0); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_approval_signed_http_changed_definition_cannot_resume() { + let f = fixture("1h", false).await; + let (run, approval) = f.start().await; + assert_eq!( + approval["definition_hash"] + .as_str() + .expect("approved revision") + .len(), + 64 + ); + let grant = f.decision(&f.owner, &approval, true, "approve original version"); + let (status, body) = f.submit(&f.owner, &grant).await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["accepted"], true, "{body}"); + sqlx::query("UPDATE workflows SET definition_hash=$3 WHERE community_id=$1 AND id=$2") + .bind(f.community.as_uuid()) + .bind(f.workflow) + .bind([9_u8; 32].as_slice()) + .execute(&f.pool) + .await + .expect("change fixture version"); + f.restarted_engine() + .recover_approvals() + .await + .expect("recover"); + let row = f.terminal(run).await; + assert_eq!(row.status, buzz_db::workflow::RunStatus::Failed); + assert_eq!(row.error_code.as_deref(), Some("owner_unauthorized")); + assert!(!row.execution_trace.to_string().contains("\"sent\":true")); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_approval_signed_http_denial_commits_terminal_audit() { + let f = fixture("1h", false).await; + let (run, approval) = f.start().await; + let deny = f.decision(&f.owner, &approval, false, "do not execute"); + let (status, body) = f.submit(&f.owner, &deny).await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["accepted"], true, "{body}"); + let row = f.terminal(run).await; + assert_eq!(row.status, buzz_db::workflow::RunStatus::Cancelled); + assert_eq!(row.error_code.as_deref(), Some("approval_denied")); + let (status, replay) = f.submit(&f.owner, &deny).await; + assert_eq!(status, 200, "{replay}"); + assert!(replay["message"] + .as_str() + .expect("receipt") + .contains("\"duplicate\":true")); + let path = format!("/workflows/{}/runs/{run}/approvals", f.workflow); + let (status, read) = f.request(&f.owner, "GET", &path, None).await; + assert_eq!(status, 200, "{read}"); + assert_eq!(read["approvals"][0]["status"], "denied"); + assert_eq!(read["approvals"][0]["decision_event_id"], deny.id.to_hex()); + let (status, read) = f.request(&f.outsider, "GET", &path, None).await; + assert_eq!(status, 403, "private approval must not leak: {read}"); +} diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index c7fa09bebd0..f3a8a390260 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -220,6 +220,10 @@ fn approval_json(approval: &buzz_db::workflow::ApprovalRecord) -> Value { "step_id": approval.step_id, "step_index": approval.step_index, "approver_spec": approval.approver_spec, + "message": approval.request_message, + "definition_hash": approval.definition_hash, + "request_event_id": approval.request_event_id.as_ref().map(hex::encode), + "decision_event_id": approval.decision_event_id.as_ref().map(hex::encode), "status": approval.status, "approver_pubkey": approval.approver_pubkey.as_ref().map(hex::encode), "note": approval.note, @@ -253,6 +257,10 @@ mod tests { step_id: "review".to_string(), step_index: 1, approver_spec: "any".to_string(), + request_message: None, + definition_hash: None, + request_event_id: None, + decision_event_id: None, status: buzz_db::workflow::ApprovalStatus::Pending, approver_pubkey: None, note: None, diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 02e2cc03a64..5f62c157609 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -279,10 +279,16 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful"); + let delegation_owner = auth_ctx + .agent_owner_pubkey + .as_ref() + .map(|owner| owner.to_bytes().to_vec()); *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); - state - .conn_manager - .set_authenticated_pubkey(conn_id, pubkey.to_bytes().to_vec()); + state.conn_manager.set_authenticated_session( + conn_id, + pubkey.to_bytes().to_vec(), + delegation_owner, + ); conn.send(RelayMessage::ok(&event_id_hex, true, "")); } Err(e) => { diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 074f6b391d0..b258a2d81d0 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -11,16 +11,15 @@ use std::sync::Arc; -use chrono::Utc; use nostr::Event; use sha2::{Digest, Sha256}; use tracing::warn; use uuid::Uuid; use buzz_core::kind::*; -use buzz_core::tenant::{CommunityId, TenantContext}; +use buzz_core::tenant::TenantContext; use buzz_datastore_tracing::datastore_span; -use buzz_db::workflow::{ApprovalStatus, RunStatus}; +use buzz_db::workflow::RunStatus; use buzz_db::DbError; use buzz_workflow::executor::TriggerContext; @@ -97,7 +96,7 @@ enum PersistResult { /// not strictly atomic: if a mutation succeeds but commit fails, the mutation /// persists without the event record. On retry, the event INSERT succeeds /// (no conflict), and the mutation re-executes — which is safe for idempotent -/// operations (open_dm, hide_dm, update_approval, upsert_workflow). +/// operations (open_dm, hide_dm, upsert_workflow). Approval decisions use their own atomic transaction. #[datastore_span(name = "persist_command_event", system = "postgresql")] async fn persist_command_event( db: &buzz_db::Db, @@ -985,148 +984,13 @@ async fn handle_workflow_trigger( }) } -/// Enforce the approver_spec field against the requesting pubkey. -/// -/// Accepted specs: -/// - `""` or `"any"` — any authenticated user may approve. -/// - 64-char lowercase hex string — only that exact pubkey may approve. -/// -/// All other formats are rejected (fail-closed). -fn check_approver_spec(approver_spec: &str, requester_hex: &str) -> Result<(), IngestError> { - let spec = approver_spec.trim(); - - // Empty or "any" — anyone may approve - if spec.is_empty() || spec == "any" { - return Ok(()); - } - - // Exact pubkey match (64-char hex, case-insensitive) - if spec.len() == 64 && spec.chars().all(|c| c.is_ascii_hexdigit()) { - if requester_hex.to_lowercase() == spec.to_lowercase() { - return Ok(()); - } - return Err(IngestError::Rejected( - "forbidden: not the designated approver for this request".into(), - )); - } - - // Role-based or unrecognised — fail closed - Err(IngestError::Rejected(format!( - "forbidden: approver spec '{}' is not yet supported", - spec - ))) -} - async fn handle_approval_grant( tenant: &TenantContext, state: &Arc, event: &Event, auth: &IngestAuth, ) -> Result { - let self_bytes = auth.pubkey().to_bytes().to_vec(); - let self_hex = hex::encode(&self_bytes); - - // 1. Extract approval reference from `e` tag (references the approval-requested event) - // or `d` tag (contains the token hash hex) - let token_hash_hex = extract_d_tag(event) - .or_else(|| extract_e_tag(event)) - .ok_or_else(|| { - IngestError::Rejected("invalid: missing approval reference (d or e tag)".into()) - })?; - - let token_hash = hex::decode(&token_hash_hex) - .map_err(|_| IngestError::Rejected("invalid: bad approval token hash hex".into()))?; - - // 2. Look up the approval record - let approval = state - .db - .get_approval_by_stored_hash(tenant.community(), &token_hash) - .await - .map_err(|_| IngestError::Rejected("invalid: approval not found".into()))?; - - // 3. Validate approval is pending and not expired - if approval.status != ApprovalStatus::Pending { - return Err(IngestError::Rejected(format!( - "invalid: approval already {}", - approval.status - ))); - } - if Utc::now() > approval.expires_at { - return Err(IngestError::Rejected( - "invalid: approval token has expired".into(), - )); - } - - // 4. Validate caller is authorized approver - check_approver_spec(&approval.approver_spec, &self_hex)?; - - // Persist the command event — returns open transaction - let tx = match persist_command_event(&state.db, tenant, event, None).await? { - PersistResult::Duplicate => { - return Ok(IngestResult { - event_id: event.id.to_hex(), - accepted: true, - message: "duplicate: already processed".into(), - }); - } - PersistResult::Inserted(tx) => tx, - }; - - // 5. Execute: update approval status to granted - let note = if event.content.is_empty() { - None - } else { - Some(event.content.as_str()) - }; - - let updated = state - .db - .update_approval_by_stored_hash( - tenant.community(), - &token_hash, - ApprovalStatus::Granted, - Some(&self_bytes), - note, - ) - .await - .map_err(|e| IngestError::Internal(format!("error: db update_approval: {e}")))?; - - if !updated { - return Err(IngestError::Rejected( - "invalid: approval already acted on (race)".into(), - )); - } - - // Finalize the idempotency record after the separate approval update succeeds. - tx.commit() - .await - .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; - - // 6. Resume workflow execution (post-commit, async) - let community_id = tenant.community(); - let run_id = approval.run_id; - let workflow_id = approval.workflow_id; - let resume_index = approval.step_index as usize + 1; - let engine = Arc::clone(&state.workflow_engine); - let db = state.db.clone(); - - tokio::spawn(async move { - resume_workflow_after_approval(engine, db, community_id, run_id, workflow_id, resume_index) - .await; - }); - - // 7. Return response - Ok(IngestResult { - event_id: event.id.to_hex(), - accepted: true, - message: format!( - "response:{}", - serde_json::json!({ - "status": "granted", - "run_id": run_id.to_string(), - }) - ), - }) + handle_approval_decision(tenant, state, event, auth, true).await } async fn handle_approval_deny( @@ -1135,237 +999,109 @@ async fn handle_approval_deny( event: &Event, auth: &IngestAuth, ) -> Result { - let self_bytes = auth.pubkey().to_bytes().to_vec(); - let self_hex = hex::encode(&self_bytes); - - // 1. Extract approval reference - let token_hash_hex = extract_d_tag(event) - .or_else(|| extract_e_tag(event)) - .ok_or_else(|| { - IngestError::Rejected("invalid: missing approval reference (d or e tag)".into()) - })?; - - let token_hash = hex::decode(&token_hash_hex) - .map_err(|_| IngestError::Rejected("invalid: bad approval token hash hex".into()))?; - - // 2. Look up the approval record - let approval = state - .db - .get_approval_by_stored_hash(tenant.community(), &token_hash) - .await - .map_err(|_| IngestError::Rejected("invalid: approval not found".into()))?; + handle_approval_decision(tenant, state, event, auth, false).await +} - // 3. Validate approval is pending and not expired - if approval.status != ApprovalStatus::Pending { - return Err(IngestError::Rejected(format!( - "invalid: approval already {}", - approval.status - ))); - } - if Utc::now() > approval.expires_at { +async fn handle_approval_decision( + tenant: &TenantContext, + state: &Arc, + event: &Event, + auth: &IngestAuth, + grant: bool, +) -> Result { + if event.pubkey != *auth.pubkey() { return Err(IngestError::Rejected( - "invalid: approval token has expired".into(), + "forbidden: approval signer differs from authenticated identity".into(), )); } - - // 4. Validate caller is authorized approver - check_approver_spec(&approval.approver_spec, &self_hex)?; - - // Persist the command event — returns open transaction - let tx = match persist_command_event(&state.db, tenant, event, None).await? { - PersistResult::Duplicate => { - return Ok(IngestResult { - event_id: event.id.to_hex(), - accepted: true, - message: "duplicate: already processed".into(), - }); - } - PersistResult::Inserted(tx) => tx, - }; - - // 5. Execute: update approval status to denied - let note = if event.content.is_empty() { - None - } else { - Some(event.content.as_str()) - }; - - let updated = state - .db - .update_approval_by_stored_hash( - tenant.community(), - &token_hash, - ApprovalStatus::Denied, - Some(&self_bytes), - note, - ) - .await - .map_err(|e| IngestError::Internal(format!("error: db update_approval: {e}")))?; - - if !updated { + // SDK/native contract uses one d-tag containing the public approval_ref. + let refs: Vec<_> = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().is_some_and(|key| key == "d")) + .collect(); + let reference = match refs.as_slice() { + [tag] if tag.as_slice().len() == 2 => hex::decode(&tag.as_slice()[1]).ok(), + _ => None, + } + .filter(|value| value.len() == 32) + .ok_or_else(|| { + IngestError::Rejected("invalid: expected one 32-byte approval reference d-tag".into()) + })?; + if event.content.len() > 4096 { return Err(IngestError::Rejected( - "invalid: approval already acted on (race)".into(), + "invalid: approval note exceeds 4096 bytes".into(), )); } - - // Finalize the idempotency record after the separate approval denial succeeds. - tx.commit() + let mut tx = state + .db + .begin_event_write_transaction() .await - .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; - - // 6. Cancel the workflow run (post-commit, async) - let community_id = tenant.community(); - let run_id = approval.run_id; - let pubkey_hex = self_hex.clone(); - let db = state.db.clone(); - - tokio::spawn(async move { - let run = match db.get_workflow_run(community_id, run_id).await { - Ok(r) => r, - Err(e) => { - tracing::error!("approval_deny: failed to fetch run {run_id}: {e}"); - return; - } - }; - - if run.status != RunStatus::WaitingApproval { - tracing::warn!( - "approval_deny: run {run_id} has status '{}', expected 'waiting_approval'", - run.status - ); - return; + .map_err(|e| IngestError::Internal(format!("error: approval transaction: {e}")))?; + buzz_deletion::store(&state.db) + .guard_transaction(&mut tx, tenant.community()) + .await + .map_err(|e| { + IngestError::Rejected(format!("restricted: community writes are fenced: {e}")) + })?; + let receipt = buzz_db::workflow::approval::decide( + &mut tx, + tenant.community(), + &reference, + event, + grant, + auth.channel_ids(), + ) + .await + .map_err(|e| match e { + buzz_db::DbError::NotFound(_) => { + IngestError::Rejected("invalid: approval not found".into()) } - - let cancel_msg = format!("workflow cancelled: approval denied by {pubkey_hex}"); - if let Err(e) = db - .update_workflow_run( - community_id, - run_id, - RunStatus::Cancelled, - run.current_step, - &run.execution_trace, - Some(buzz_db::workflow::WorkflowRunFailure { - code: "approval_denied", - message: &cancel_msg, - }), - ) + buzz_db::DbError::AccessDenied(_) => { + IngestError::Rejected("forbidden: not a current designated channel approver".into()) + } + buzz_db::DbError::InvalidData(message) => { + IngestError::Rejected(format!("invalid: {message}")) + } + other => IngestError::Internal(format!("error: approval decision: {other}")), + })?; + tx.commit() + .await + .map_err(|e| IngestError::Internal(format!("error: approval commit: {e}")))?; + if !receipt.duplicate { + match state + .db + .get_event_by_id_for_event_write(tenant.community(), event.id.as_bytes()) .await { - tracing::error!("approval_deny: failed to cancel run {run_id}: {e}"); + Ok(Some(stored)) => { + let _ = super::event::dispatch_persistent_event( + tenant, + state, + &stored, + event.kind.as_u16() as u32, + &event.pubkey.to_hex(), + None, + ) + .await; + } + other => { + tracing::warn!("Committed approval decision live fanout unavailable: {other:?}") + } } - }); - - // 7. Return response + } + // The durable recovery worker claims ready continuations; no volatile spawn + // is needed to make this committed decision progress after a process restart. Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: format!( "response:{}", - serde_json::json!({ - "status": "denied", - "run_id": run_id.to_string(), - }) + serde_json::json!({"status":receipt.status,"run_id":receipt.run_id,"decision_event_id":event.id.to_hex(),"duplicate":receipt.duplicate}) ), }) } -/// Resume a suspended workflow run after an approval gate has been granted. -async fn resume_workflow_after_approval( - engine: Arc, - db: buzz_db::Db, - community_id: CommunityId, - run_id: Uuid, - workflow_id: Uuid, - resume_index: usize, -) { - let run = match db.get_workflow_run(community_id, run_id).await { - Ok(r) => r, - Err(e) => { - tracing::error!("resume_workflow: failed to fetch run {run_id}: {e}"); - return; - } - }; - - // Guard: only resume runs that are actually waiting for approval - if run.status != RunStatus::WaitingApproval { - tracing::warn!( - "resume_workflow: run {run_id} has status '{}', expected 'waiting_approval'", - run.status - ); - return; - } - - let workflow = match db.get_workflow(community_id, workflow_id).await { - Ok(w) => w, - Err(e) => { - tracing::error!("resume_workflow: failed to fetch workflow {workflow_id}: {e}"); - return; - } - }; - - let def: buzz_workflow::WorkflowDef = match serde_json::from_value(workflow.definition.clone()) - { - Ok(d) => d, - Err(e) => { - tracing::error!("resume_workflow: failed to parse workflow definition: {e}"); - if let Err(db_err) = db - .update_workflow_run( - community_id, - run_id, - RunStatus::Failed, - run.current_step, - &run.execution_trace, - Some(buzz_db::workflow::WorkflowRunFailure { - code: "invalid_definition", - message: &format!("definition parse error: {e}"), - }), - ) - .await - { - tracing::error!("resume_workflow: failed to mark run as failed: {db_err}"); - } - return; - } - }; - - // Reconstruct step_outputs from execution trace for template resolution - let mut initial_outputs: std::collections::HashMap = - std::collections::HashMap::new(); - if let Some(trace_arr) = run.execution_trace.as_array() { - for entry in trace_arr { - if let (Some(step_id), Some(output)) = ( - entry.get("step_id").and_then(|v| v.as_str()), - entry.get("output"), - ) { - initial_outputs.insert(step_id.to_string(), output.clone()); - } - } - } - - // Restore trigger context for {{trigger.*}} templates - let trigger_ctx: TriggerContext = run - .trigger_context - .as_ref() - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - - // Execute remaining steps - let existing_trace = run.execution_trace.as_array().cloned(); - let result = buzz_workflow::executor::execute_from_step( - &engine, - community_id, - run_id, - &def, - &trigger_ctx, - resume_index, - Some(initial_outputs), - ) - .await; - engine - .finalize_run(community_id, run_id, result, existing_trace) - .await; -} - #[cfg(test)] mod postgres_tests { use super::*; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index e2e7db02b09..a7316c8f235 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -437,6 +437,7 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError { /// Returns `Err` for unknown kinds — the relay rejects them. fn required_scope_for_kind(kind: u32, event: &Event) -> Result { match kind { + buzz_core::kind::KIND_MACHINE_ENROLLMENT | buzz_core::kind::KIND_MACHINE_OBSERVATION => Ok(Scope::UsersWrite), KIND_PROFILE => Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM @@ -2276,10 +2277,22 @@ async fn ingest_event_inner( .tags .iter() .any(|tag| tag.as_slice().first().map(String::as_str) == Some("protocol")); - if is_job_kind && has_protocol_tag { - buzz_core::cml_event::validate_cml_event_after_signature(&event) - .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; - } + let is_fleet_event = if is_job_kind && has_protocol_tag { + if buzz_core::fleet::is_receipt(&event) { + buzz_core::fleet::FleetReceipt::from_event_after_signature(&event) + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + true + } else { + // Other protocol-tagged jobs retain the strict CML validation path. + let cml = buzz_core::cml_event::validate_cml_event_after_signature(&event) + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + buzz_core::fleet::FleetScope::from_task(&cml.task) + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))? + .is_some() + } + } else { + false + }; let is_gift_wrap = kind_u32 == KIND_GIFT_WRAP; if event.pubkey != *auth.pubkey() && !is_gift_wrap { @@ -2430,6 +2443,28 @@ async fn ingest_event_inner( } } + if matches!( + kind_u32, + buzz_core::kind::KIND_MACHINE_ENROLLMENT | buzz_core::kind::KIND_MACHINE_OBSERVATION + ) { + if auth.channel_ids().is_some() { + return Err(IngestError::AuthFailed( + "restricted: machine commands require a global token".into(), + )); + } + let inserted = super::machine::handle(state, tenant, &event).await?; + emit_product_feedback_success(tracer, tenant, &event, &auth); + return Ok(IngestResult { + event_id: event_id_hex, + accepted: true, + message: if inserted { + String::new() + } else { + "duplicate: machine command already accepted".into() + }, + }); + } + let mut channel_id = if kind_u32 == KIND_REACTION { match derive_reaction_channel(tenant.community(), &state.db, &event).await { ReactionChannelResult::Channel(ch_id) => Some(ch_id), @@ -3176,7 +3211,27 @@ async fn ingest_event_inner( }); } - let (stored_event, was_inserted) = if buzz_core::kind::is_replaceable(kind_u32) { + let (stored_event, was_inserted) = if is_fleet_event { + // All normal ingress checks above still apply. The signed event, + // thread metadata and attempt projection share one commit; dispatch + // below occurs only after it succeeds. + state + .db + .insert_fleet_event( + tenant.community(), + &event, + channel_id, + thread_meta.as_ref().map(|metadata| metadata.as_params()), + ) + .await + .map_err(|error| match error { + buzz_db::DbError::AccessDenied(message) + | buzz_db::DbError::InvalidData(message) => { + IngestError::Rejected(format!("restricted: {message}")) + } + other => IngestError::Internal(format!("error: {other}")), + })? + } else if buzz_core::kind::is_replaceable(kind_u32) { // NIP-16 replaceable event — atomic replace with stale-write protection. // channel_id is None for global kinds (0, 1, 3) due to step 5b above. state diff --git a/crates/buzz-relay/src/handlers/machine.rs b/crates/buzz-relay/src/handlers/machine.rs new file mode 100644 index 00000000000..cbdf0677c1e --- /dev/null +++ b/crates/buzz-relay/src/handlers/machine.rs @@ -0,0 +1,63 @@ +//! Private control commands terminate here, before ordinary storage or fanout. + +use crate::{handlers::ingest::IngestError, state::AppState}; +use buzz_core::{machine::MachineCommand, TenantContext}; +use nostr::{Event, PublicKey}; +use std::sync::Arc; + +/// Validate the owner delegation and atomically persist a private command. +pub async fn handle( + state: &Arc, + tenant: &TenantContext, + event: &Event, +) -> Result { + let invalid = |_| IngestError::Rejected("invalid: machine command or owner proof".into()); + let command = MachineCommand::from_event_after_signature(event).map_err(invalid)?; + if let MachineCommand::Enroll(enrollment) = command { + let signer = event.pubkey; + let created_at = event.created_at.as_secs(); + tokio::task::spawn_blocking(move || -> Result<(), String> { + buzz_core::machine::verify_enrollment_consent( + &enrollment, + &signer, + created_at, + chrono::Utc::now().timestamp(), + )?; + let coordinator = PublicKey::from_hex(&enrollment.coordinator_pubkey) + .map_err(|_| "invalid coordinator")?; + let proof = + serde_json::to_string(&enrollment.owner_auth).map_err(|_| "invalid owner proof")?; + let owner = buzz_sdk::nip_oa::verify_auth_tag_for_event( + &proof, + &coordinator, + buzz_core::kind::KIND_MACHINE_ENROLLMENT, + created_at, + ) + .map_err(|_| "owner proof does not authorize enrollment")?; + if owner != signer { + return Err("enrollment signer does not own coordinator".into()); + } + Ok(()) + }) + .await + .map_err(|_| IngestError::Internal("machine proof verification worker failed".into()))? + .map_err(|_| IngestError::Rejected("invalid: owner proof or coordinator consent".into()))?; + } + state + .db + .apply_machine_command(tenant.community(), event) + .await + .map_err(|error| match error { + buzz_db::DbError::AccessDenied(_) | buzz_db::DbError::InvalidData(_) => { + IngestError::Rejected("restricted: machine command unavailable".into()) + } + buzz_db::DbError::Sqlx(sqlx::Error::Database(ref db_error)) + if db_error.code().as_deref() == Some("23505") => + { + IngestError::Rejected( + "restricted: machine or coordinator already registered".into(), + ) + } + other => IngestError::Internal(format!("machine command persistence failed: {other}")), + }) +} diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index 2f4aa00b595..4300ceae79e 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -66,3 +66,6 @@ pub fn resolve_ttl(event: &nostr::Event, ephemeral_ttl_override: Option) -> (ttl, _) => ttl, } } + +/// Private machine control persistence. +pub mod machine; diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 206f0329c0e..99588e41591 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -574,10 +574,12 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { let cfg = buzz_relay::api::git::store::ProbeConfig { race_width, race_rounds, + ..Default::default() }; tracing::info!( race_width, race_rounds, + timeout_seconds = cfg.total_timeout.as_secs(), "running git object-store conformance probe (A3 gate)" ); let report = state @@ -1049,40 +1051,8 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { // banned member's next auth attempt at the auth seam. { let state_for_conn_ctrl = Arc::clone(&state); - let mut rx = state_for_conn_ctrl.pubsub.subscribe_conn_control(); - tokio::spawn(async move { - loop { - match rx.recv().await { - Ok(scoped) => match scoped.command { - buzz_pubsub::conn_control::ConnControl::DisconnectCommunity => { - state_for_conn_ctrl - .community_connections - .disconnect_community(scoped.community_id); - } - buzz_pubsub::conn_control::ConnControl::DisconnectPubkey { - pubkey, - event_id, - reason, - } => { - state_for_conn_ctrl.conn_manager.disconnect_pubkey( - scoped.community_id, - &pubkey, - &event_id, - &reason, - ); - } - }, - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - metrics::counter!("buzz_conn_control_lag_total").increment(n); - tracing::warn!("Connection-control consumer lagged by {n} messages"); - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - tracing::error!("Connection-control broadcast channel closed"); - break; - } - } - } - }); + let rx = state_for_conn_ctrl.pubsub.subscribe_conn_control(); + tokio::spawn(state_for_conn_ctrl.run_connection_control(rx)); } let router = build_router(Arc::clone(&state)); diff --git a/crates/buzz-relay/src/protocol.rs b/crates/buzz-relay/src/protocol.rs index ea969008dc4..29f3e06de1f 100644 --- a/crates/buzz-relay/src/protocol.rs +++ b/crates/buzz-relay/src/protocol.rs @@ -3,7 +3,7 @@ //! # Buzz extension frames //! //! Alongside the NIP-01 relay→client messages ([`RelayMessage`]), the relay -//! emits one Buzz-specific extension frame: +//! emits two Buzz-specific extension frames: //! //! ## `BUZZ_SYNC_REQUIRED` //! @@ -24,6 +24,22 @@ //! - Clients that do not recognize the frame MUST ignore it (unknown //! relay→client array heads are non-fatal per NIP-01 client convention); //! the reconnect-replay machinery remains the backstop either way. +//! +//! ## `BUZZ_TASKS_SYNC_REQUIRED` +//! +//! ```text +//! ["BUZZ_TASKS_SYNC_REQUIRED",""] +//! ["BUZZ_TASKS_SYNC_REQUIRED",null] +//! ``` +//! +//! Advisory invalidation signal telling clients that tasks in the named +//! channel, or community-wide tasks when the scope is `null`, changed and they +//! should refetch through the existing authorized HTTP API. Delivered on the +//! connection's priority control channel. The payload carries only the scope — no task content +//! (no id, title, status, assignee, or actor). A connection that lacks +//! existing channel visibility receives no frame at all, so the signal +//! cannot become an existence oracle. Clients treat the frame as advice +//! only; relay truth arrives via the authorized GET. use nostr::{Event, Filter}; use serde_json::Value; @@ -287,6 +303,16 @@ impl RelayMessage { pub fn sync_required() -> String { serde_json::json!(["BUZZ_SYNC_REQUIRED", "backpressure"]).to_string() } + + /// Format a `BUZZ_TASKS_SYNC_REQUIRED` extension frame (see module docs). + /// + /// Advisory task invalidation signal. `Some(channel_id)` identifies the + /// channel whose tasks changed; `None` is community-wide. No task content + /// is carried. Channel-scoped frames are delivered only to connections + /// that have current visibility into that channel. + pub fn tasks_sync_required(channel_id: Option<&uuid::Uuid>) -> String { + serde_json::json!(["BUZZ_TASKS_SYNC_REQUIRED", channel_id]).to_string() + } } #[cfg(test)] @@ -580,6 +606,19 @@ mod tests { assert_eq!(v[1], "backpressure"); }), ), + ( + "tasks_sync_required", + Box::new(|| { + let channel_id = uuid::Uuid::nil(); + let msg = RelayMessage::tasks_sync_required(Some(&channel_id)); + let v: Value = serde_json::from_str(&msg).unwrap(); + assert_eq!(v[0], "BUZZ_TASKS_SYNC_REQUIRED"); + assert_eq!(v[1], "00000000-0000-0000-0000-000000000000"); + let community: Value = + serde_json::from_str(&RelayMessage::tasks_sync_required(None)).unwrap(); + assert!(community[1].is_null()); + }), + ), ]; for (name, check) in cases { diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index dae7e54b67f..d2c93d5dc4f 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -122,6 +122,8 @@ pub fn build_router(state: Arc) -> Router { post(api::invites::accept_policy), ) .route("/api/invites/claim", post(api::invites::claim_invite)) + .route("/api/machines", get(api::machines::list_machines)) + .route("/api/machines/{id}", get(api::machines::get_machine)) // Tasks: relay-owned work items (NIP-98 auth + relay membership). // Host-derived tenant, like every other route here — the community is // never a path segment. @@ -133,6 +135,10 @@ pub fn build_router(state: Arc) -> Router { "/api/tasks/{task_id}", get(api::tasks::get_task).patch(api::tasks::update_task), ) + .route( + "/api/tasks/{task_id}/attempts/{attempt_id}/admission", + get(api::tasks::get_fleet_admission), + ) .route( "/api/tasks/{task_id}/events", post(api::tasks::append_task_event), diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 2010acfbfe2..ca6c9ab7547 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1,5 +1,8 @@ //! Shared application state — Arc-wrapped, shared across all connections. +mod connection_control; +mod task_invalidation; + use std::collections::{HashMap, HashSet}; use std::future::Future; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; @@ -90,6 +93,14 @@ const RESTART_CLOSE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from type SlidingWindowCounter = (u32, Instant); type ScopedRateLimiter = DashMap; +/// Authentication facts published atomically after successful NIP-42 verification. +#[derive(Clone)] +struct AuthenticatedSession { + pubkey: Vec, + delegation_owner: Option>, + generation: Uuid, +} + /// Per-connection entry in the connection manager. struct ConnEntry { tx: mpsc::Sender, @@ -106,7 +117,7 @@ struct ConnEntry { /// broadcasts track the same consecutive-full counter. backpressure_count: Arc, subscriptions: ConnectionSubscriptions, - authenticated_pubkey: Arc>>>, + authenticated_session: Arc>>, grace_limit: u8, /// Flipped exactly once, by the first backpressure-driven disconnect of /// this connection (see `ConnectionManager::count_backpressure_disconnect_and_cancel`), @@ -285,7 +296,7 @@ impl ConnectionManager { community_id, backpressure_count, subscriptions, - authenticated_pubkey: Arc::new(std::sync::RwLock::new(None)), + authenticated_session: Arc::new(std::sync::RwLock::new(None)), grace_limit, backpressure_disconnect_counted: AtomicBool::new(false), }, @@ -311,9 +322,23 @@ impl ConnectionManager { /// Record the authenticated pubkey for a connection after NIP-42 succeeds. pub fn set_authenticated_pubkey(&self, conn_id: Uuid, pubkey_bytes: Vec) { + self.set_authenticated_session(conn_id, pubkey_bytes, None); + } + + /// Retain only the delegation owner verified by this connection's AUTH flow. + pub(crate) fn set_authenticated_session( + &self, + conn_id: Uuid, + pubkey_bytes: Vec, + delegation_owner: Option>, + ) { if let Some(entry) = self.connections.get(&conn_id) { - if let Ok(mut slot) = entry.authenticated_pubkey.write() { - *slot = Some(pubkey_bytes); + if let Ok(mut slot) = entry.authenticated_session.write() { + *slot = Some(AuthenticatedSession { + pubkey: pubkey_bytes, + delegation_owner, + generation: Uuid::new_v4(), + }); } } } @@ -334,13 +359,13 @@ impl ConnectionManager { .filter_map(|entry| { let matches = entry.community_id == community_id && entry - .authenticated_pubkey + .authenticated_session .read() .ok() .and_then(|value| { value .as_ref() - .map(|stored| stored.as_slice() == pubkey_bytes) + .map(|stored| stored.pubkey.as_slice() == pubkey_bytes) }) .unwrap_or(false); matches.then_some(*entry.key()) @@ -352,7 +377,8 @@ impl ConnectionManager { pub fn pubkey_for_conn(&self, conn_id: Uuid) -> Option> { self.connections .get(&conn_id) - .and_then(|entry| entry.authenticated_pubkey.read().ok()?.clone()) + .and_then(|entry| entry.authenticated_session.read().ok()?.clone()) + .map(|session| session.pubkey) } /// Disconnect every live connection authenticated as `pubkey` **in @@ -555,11 +581,11 @@ impl ConnectionManager { // community_id → set of pubkey bytes let mut seen: HashMap>> = HashMap::new(); for entry in self.connections.iter() { - if let Ok(lock) = entry.authenticated_pubkey.read() { + if let Ok(lock) = entry.authenticated_session.read() { if let Some(pk) = lock.as_ref() { seen.entry(entry.community_id) .or_default() - .insert(pk.clone()); + .insert(pk.pubkey.clone()); } } } @@ -572,7 +598,8 @@ impl ConnectionManager { pub fn pubkey_for(&self, conn_id: Uuid) -> Option> { self.connections .get(&conn_id) - .and_then(|entry| entry.authenticated_pubkey.read().ok()?.clone()) + .and_then(|entry| entry.authenticated_session.read().ok()?.clone()) + .map(|session| session.pubkey) } /// Sends a text message to the given connection. @@ -804,6 +831,9 @@ pub struct AppState { /// admissions when an in-memory audio room is recreated at the same roster /// revision after a restart. Mesh rooms use their Redis-fenced generation. pub huddle_liveness_generation: Uuid, + /// Per-process generation used to suppress Redis task-invalidation echoes + /// after this relay has already delivered the advisory locally. + pub task_invalidation_generation: Uuid, /// Recently-published event IDs for local-echo deduplication, keyed by /// `(community_id, event_id)`. Events fanned out in-process are added here; @@ -1016,6 +1046,7 @@ impl AppState { workflow_engine, relay_keypair, huddle_liveness_generation: Uuid::new_v4(), + task_invalidation_generation: Uuid::new_v4(), local_event_ids: Arc::new( moka::sync::Cache::builder() @@ -1303,6 +1334,39 @@ impl AppState { } } + /// Apply one community-scoped cross-pod connection command locally. + /// Received task invalidations are generation-fenced so a publisher does + /// not redeliver its own already-applied advisory. + pub async fn apply_conn_control(&self, scoped: buzz_pubsub::conn_control::ScopedConnControl) { + match scoped.command { + ConnControl::InvalidateTasks { + channel_id, + origin_generation, + } => { + if origin_generation != self.task_invalidation_generation { + self.deliver_task_invalidation(scoped.community_id, channel_id) + .await; + } + } + ConnControl::DisconnectCommunity => { + self.community_connections + .disconnect_community(scoped.community_id); + } + ConnControl::DisconnectPubkey { + pubkey, + event_id, + reason, + } => { + self.conn_manager.disconnect_pubkey( + scoped.community_id, + &pubkey, + &event_id, + &reason, + ); + } + } + } + /// Enforce a live ban cluster-wide: close this pod's sockets for `pubkey` /// now (fenced to `tenant`'s community) and fan the same disconnect out to /// every other pod over the conn-control Redis channel. diff --git a/crates/buzz-relay/src/state/connection_control.rs b/crates/buzz-relay/src/state/connection_control.rs new file mode 100644 index 00000000000..99da7489989 --- /dev/null +++ b/crates/buzz-relay/src/state/connection_control.rs @@ -0,0 +1,77 @@ +//! Keep urgent socket control independent of bounded advisory authorization. + +use super::{AppState, CommunityId, ConnControl, Uuid}; +use buzz_pubsub::conn_control::ScopedConnControl; +use futures_util::{stream::FuturesUnordered, StreamExt}; +use std::{collections::VecDeque, sync::Arc}; +use tokio::sync::broadcast; + +const MAX_PENDING_TASK_SCOPES: usize = 256; + +impl AppState { + /// Consume cross-pod commands without awaiting advisory database work. + /// One active fanout and a bounded, duplicate-coalescing FIFO preserve + /// urgent control responsiveness. Loss forces fresh authorization on reconnect. + pub async fn run_connection_control( + self: Arc, + mut rx: broadcast::Receiver, + ) { + let mut pending: VecDeque<(CommunityId, Option)> = VecDeque::new(); + let mut active = FuturesUnordered::new(); + loop { + tokio::select! { + _ = active.next(), if !active.is_empty() => {} + received = rx.recv() => match received { + Ok(ScopedConnControl { community_id, command: ConnControl::InvalidateTasks { channel_id, origin_generation } }) => { + if origin_generation != self.task_invalidation_generation { + let scope = (community_id, channel_id); + if !pending.contains(&scope) { + if pending.len() < MAX_PENDING_TASK_SCOPES { + pending.push_back(scope); + } else { + // Dropping an advisory without recovery could leave a + // connected client stale forever. Close only its tenant. + self.reconnect_after_control_loss(Some(community_id)); + pending.retain(|(community, _)| *community != community_id); + metrics::counter!("buzz_task_control_overflow_total").increment(1); + } + } + } + } + Ok(scoped) => self.apply_conn_control(scoped).await, + Err(broadcast::error::RecvError::Lagged(n)) => { + metrics::counter!("buzz_conn_control_lag_total").increment(n); + tracing::warn!("Connection-control consumer lost {n} commands; reconnecting sockets"); + // The missed command might revoke any tenant/key. Its + // durable authorization is checked on the next connection. + self.reconnect_after_control_loss(None); + pending.clear(); + active.clear(); + } + Err(broadcast::error::RecvError::Closed) => { + self.reconnect_after_control_loss(None); + tracing::error!("Connection-control broadcast channel closed"); + break; + } + } + } + if active.is_empty() { + if let Some((community, channel)) = pending.pop_front() { + let state = self.clone(); + active.push(async move { + state.deliver_task_invalidation(community, channel).await; + }); + } + } + } + } + + fn reconnect_after_control_loss(&self, community: Option) { + for entry in self.community_connections.connections.iter() { + if community.is_none_or(|id| id == entry.value().0) { + // A recovery close must never claim the community was deleted. + entry.value().1.cancel.cancel(); + } + } + } +} diff --git a/crates/buzz-relay/src/state/task_invalidation.rs b/crates/buzz-relay/src/state/task_invalidation.rs new file mode 100644 index 00000000000..839ffff0dd0 --- /dev/null +++ b/crates/buzz-relay/src/state/task_invalidation.rs @@ -0,0 +1,394 @@ +//! Task HTTP writes invalidate live clients after the durable mutation commits. + +use super::{AppState, CommunityId, ConnectionManager, HashMap, Uuid, WsMessage}; +use buzz_core::TenantContext; +use buzz_pubsub::conn_control::ConnControl; +use std::{collections::HashSet, time::Duration}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct TaskRecipientScope { + pubkey: Vec, + delegation_owner: Option>, +} + +#[derive(Clone, Copy)] +struct TaskRecipientLease { + connection_id: Uuid, + generation: Uuid, +} + +impl ConnectionManager { + /// Group equal session permissions without retaining guards across await. + fn task_recipients( + &self, + community_id: CommunityId, + ) -> HashMap> { + let mut recipients: HashMap> = HashMap::new(); + for entry in self.connections.iter() { + if entry.community_id != community_id || entry.cancel.is_cancelled() { + continue; + } + if let Ok(identity) = entry.authenticated_session.read() { + if let Some(identity) = identity.as_ref() { + recipients + .entry(TaskRecipientScope { + pubkey: identity.pubkey.clone(), + delegation_owner: identity.delegation_owner.clone(), + }) + .or_default() + .push(TaskRecipientLease { + connection_id: *entry.key(), + generation: identity.generation, + }); + } + } + } + recipients + } + + /// Fence the complete authenticated session after asynchronous authorization. + fn send_task_invalidation( + &self, + lease: TaskRecipientLease, + community_id: CommunityId, + scope: &TaskRecipientScope, + frame: WsMessage, + ) { + self.finish_task_invalidation(lease, community_id, scope, Some(frame)); + } + + /// A missing advisory forces recovery, fenced to the captured session. + fn finish_task_invalidation( + &self, + lease: TaskRecipientLease, + community_id: CommunityId, + scope: &TaskRecipientScope, + frame: Option, + ) { + let Some(entry) = self.connections.get(&lease.connection_id) else { + return; + }; + if entry.community_id != community_id || entry.cancel.is_cancelled() { + return; + } + let Ok(identity) = entry.authenticated_session.read() else { + return; + }; + let Some(identity) = identity.as_ref() else { + return; + }; + if identity.pubkey != scope.pubkey + || identity.delegation_owner != scope.delegation_owner + || identity.generation != lease.generation + { + return; + } + let delivered = frame.is_some_and(|frame| entry.ctrl_tx.try_send(frame).is_ok()); + if !delivered { + entry.cancel.cancel(); + metrics::counter!("buzz_tasks_invalidation_dropped_total").increment(1); + } + } +} + +impl AppState { + async fn task_recipient_is_relay_member( + &self, + community_id: CommunityId, + scope: &TaskRecipientScope, + ) -> Result { + if !self.config.require_relay_membership { + return Ok(true); + } + if self + .db + .is_relay_member_writer(community_id, &hex::encode(&scope.pubkey)) + .await? + { + return Ok(true); + } + if !self.config.allow_nip_oa_auth { + return Ok(false); + } + // A persisted owner association cannot substitute for a verified + // delegation on this connection. Owner membership is re-read on writer. + match scope.delegation_owner.as_deref() { + Some(owner) => { + self.db + .is_relay_member_writer(community_id, &hex::encode(owner)) + .await + } + None => Ok(false), + } + } + + /// Notify local and remote authenticated sockets after a committed task write. + /// + /// Channel advisories carry only their UUID and use a fresh writer-backed + /// access read, never a cached allow. Community advisories carry `null`. + /// No task contents or task identifiers are broadcast. Both local fanout + /// and Redis publication are bounded; advisory failure cannot turn a + /// committed HTTP write into an error. + pub(crate) async fn invalidate_tasks( + &self, + community_id: CommunityId, + channel_id: Option, + ) { + self.deliver_task_invalidation(community_id, channel_id) + .await; + + let tenant = TenantContext::resolved(community_id, "task-invalidation.internal"); + let command = ConnControl::InvalidateTasks { + channel_id, + origin_generation: self.task_invalidation_generation, + }; + match tokio::time::timeout( + Duration::from_secs(1), + self.pubsub.publish_conn_control(&tenant, &command), + ) + .await + { + Ok(Ok(_)) => {} + Ok(Err(error)) => { + tracing::warn!(%community_id, %error, "task invalidation publish failed"); + metrics::counter!("buzz_tasks_invalidation_publish_errors_total").increment(1); + } + Err(_) => { + tracing::warn!(%community_id, "task invalidation publish timed out"); + metrics::counter!("buzz_tasks_invalidation_publish_timeouts_total").increment(1); + } + } + } + + /// Apply a task invalidation only to sockets held by this relay process. + /// Cross-node consumers call this without re-publishing. + pub async fn deliver_task_invalidation( + &self, + community_id: CommunityId, + channel_id: Option, + ) { + let recipients = self.conn_manager.task_recipients(community_id); + let mut completed = HashSet::new(); + let fanout = async { + let frame = WsMessage::Text( + crate::protocol::RelayMessage::tasks_sync_required(channel_id.as_ref()).into(), + ); + for (scope, connections) in &recipients { + match self + .task_recipient_is_relay_member(community_id, scope) + .await + { + Ok(true) => {} + Ok(false) => { + completed.insert(scope.clone()); + continue; + } + Err(error) => { + tracing::warn!(%community_id, %error, "task invalidation relay membership lookup failed"); + metrics::counter!("buzz_tasks_invalidation_access_errors_total") + .increment(1); + continue; + } + } + if let Some(channel_id) = channel_id { + let channels = match self + .db + .get_accessible_channel_ids(community_id, &scope.pubkey) + .await + { + Ok(channels) => channels, + Err(error) => { + tracing::warn!(%community_id, %error, "task invalidation access lookup failed"); + metrics::counter!("buzz_tasks_invalidation_access_errors_total") + .increment(1); + continue; + } + }; + if !channels.contains(&channel_id) { + completed.insert(scope.clone()); + continue; + } + } + for &lease in connections { + self.conn_manager.send_task_invalidation( + lease, + community_id, + scope, + frame.clone(), + ); + } + completed.insert(scope.clone()); + } + }; + if tokio::time::timeout(Duration::from_secs(5), fanout) + .await + .is_err() + { + tracing::warn!(%community_id, "task invalidation fanout timed out"); + metrics::counter!("buzz_tasks_invalidation_timeouts_total").increment(1); + } + // A failed lookup is not a denial. Recover only unresolved sessions; + // never disclose a channel identifier without successful authorization. + for (scope, connections) in recipients { + if completed.contains(&scope) { + continue; + } + for lease in connections { + self.conn_manager + .finish_task_invalidation(lease, community_id, &scope, None); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{atomic::AtomicU8, Arc}; + use tokio::sync::{mpsc, Mutex}; + use tokio_util::sync::CancellationToken; + + fn connection( + manager: &ConnectionManager, + community: CommunityId, + pubkey: &[u8], + ) -> (Uuid, mpsc::Receiver, CancellationToken) { + let id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(1); + let (ctrl, rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + manager.register( + id, + tx, + ctrl, + None, + cancel.clone(), + community, + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + manager.set_authenticated_pubkey(id, pubkey.to_vec()); + (id, rx, cancel) + } + + fn snapshot( + manager: &ConnectionManager, + community: CommunityId, + ) -> (TaskRecipientScope, TaskRecipientLease) { + let recipients = manager.task_recipients(community); + assert_eq!(recipients.len(), 1); + let (scope, leases) = recipients.into_iter().next().unwrap(); + assert_eq!(leases.len(), 1); + (scope, leases[0]) + } + + #[test] + fn identity_change_after_snapshot_cannot_receive_previous_recipients_frame() { + let manager = ConnectionManager::new(); + let community = CommunityId::from_uuid(Uuid::new_v4()); + let (id, mut rx, _) = connection(&manager, community, &[1; 32]); + let (old_scope, old_lease) = snapshot(&manager, community); + manager.set_authenticated_pubkey(id, vec![2; 32]); + manager.send_task_invalidation( + old_lease, + community, + &old_scope, + WsMessage::Text("old".into()), + ); + assert!(rx.try_recv().is_err()); + let (scope, lease) = snapshot(&manager, community); + manager.send_task_invalidation(lease, community, &scope, WsMessage::Text("current".into())); + assert!(rx.try_recv().is_ok()); + } + + #[test] + fn same_key_returning_to_prior_scope_cannot_reuse_an_old_authorization() { + let manager = ConnectionManager::new(); + let community = CommunityId::from_uuid(Uuid::new_v4()); + let (id, mut rx, _) = connection(&manager, community, &[1; 32]); + let (old_scope, old_lease) = snapshot(&manager, community); + manager.set_authenticated_session(id, vec![1; 32], Some(vec![2; 32])); + let (delegated_scope, delegated_lease) = snapshot(&manager, community); + manager.set_authenticated_pubkey(id, vec![1; 32]); + manager.send_task_invalidation( + old_lease, + community, + &old_scope, + WsMessage::Text("old direct".into()), + ); + manager.send_task_invalidation( + delegated_lease, + community, + &delegated_scope, + WsMessage::Text("old delegated".into()), + ); + assert!(rx.try_recv().is_err()); + let (scope, lease) = snapshot(&manager, community); + manager.send_task_invalidation(lease, community, &scope, WsMessage::Text("current".into())); + assert!(rx.try_recv().is_ok()); + } + + #[test] + fn same_key_with_different_verified_owners_has_separate_permission_groups() { + let manager = ConnectionManager::new(); + let community = CommunityId::from_uuid(Uuid::new_v4()); + let (_direct, _rx, _) = connection(&manager, community, &[1; 32]); + let (delegated, _rx2, _) = connection(&manager, community, &[1; 32]); + manager.set_authenticated_session(delegated, vec![1; 32], Some(vec![2; 32])); + let recipients = manager.task_recipients(community); + assert_eq!(recipients.len(), 2); + assert!(recipients + .keys() + .any(|scope| scope.delegation_owner.is_none())); + assert!(recipients + .keys() + .any(|scope| scope.delegation_owner == Some(vec![2; 32]))); + } + + #[test] + fn recovery_disconnect_is_fenced_to_the_captured_session_and_community() { + let manager = ConnectionManager::new(); + let community = CommunityId::from_uuid(Uuid::new_v4()); + let other_community = CommunityId::from_uuid(Uuid::new_v4()); + let (id, mut rx, cancel) = connection(&manager, community, &[1; 32]); + let (_other_id, _other_rx, other_cancel) = connection(&manager, other_community, &[1; 32]); + let (old_scope, old_lease) = snapshot(&manager, community); + manager.set_authenticated_session(id, vec![1; 32], Some(vec![2; 32])); + manager.set_authenticated_pubkey(id, vec![1; 32]); + manager.finish_task_invalidation(old_lease, community, &old_scope, None); + assert!( + !cancel.is_cancelled(), + "old generation cannot cancel a new session" + ); + let (scope, lease) = snapshot(&manager, community); + manager.finish_task_invalidation(lease, other_community, &scope, None); + assert!(!cancel.is_cancelled(), "community must match the lease"); + manager.finish_task_invalidation(lease, community, &scope, None); + assert!( + cancel.is_cancelled(), + "unresolved current session must reconnect" + ); + assert!( + !other_cancel.is_cancelled(), + "other communities remain connected" + ); + assert!( + rx.try_recv().is_err(), + "recovery carries no channel advisory" + ); + } + + #[test] + fn full_authorized_control_queue_forces_reconnect_recovery() { + let manager = ConnectionManager::new(); + let community = CommunityId::from_uuid(Uuid::new_v4()); + let (_id, _rx, cancel) = connection(&manager, community, &[1; 32]); + let (scope, lease) = snapshot(&manager, community); + manager.send_task_invalidation(lease, community, &scope, WsMessage::Text("first".into())); + assert!(!cancel.is_cancelled()); + manager.send_task_invalidation(lease, community, &scope, WsMessage::Text("second".into())); + assert!(cancel.is_cancelled()); + assert!(manager.task_recipients(community).is_empty()); + } +} diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 0e4fb8bb532..f5cfde99510 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -235,6 +235,99 @@ impl RelayActionSink { } impl ActionSink for RelayActionSink { + fn request_approval( + &self, + wait: buzz_db::workflow::approval::ApprovalWait, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let state = self + .state + .upgrade() + .ok_or_else(|| ActionSinkError::Database("relay is shutting down".into()))?; + let channel = state + .db + .get_channel_for_event_write(wait.community_id, wait.channel_id) + .await + .map_err(|e| ActionSinkError::Database(e.to_string()))?; + if channel.archived_at.is_some() { + return Err(ActionSinkError::ChannelArchived( + wait.channel_id.to_string(), + )); + } + let host = state + .db + .lookup_community_host(wait.community_id) + .await + .map_err(|e| ActionSinkError::Database(e.to_string()))? + .ok_or_else(|| { + ActionSinkError::Database("approval community is unavailable".into()) + })?; + let tenant = buzz_core::TenantContext::resolved(wait.community_id, host); + use sha2::{Digest, Sha256}; + let revision = wait + .continuation + .get("definition_hash") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + ActionSinkError::InvalidInput("approval has no workflow revision".into()) + })?; + let snapshot_bytes = serde_json::to_vec(&wait.continuation) + .map_err(|e| ActionSinkError::InvalidInput(e.to_string()))?; + let snapshot_hash = hex::encode(Sha256::digest(&snapshot_bytes)); + let mut tags = vec![ + Tag::parse(["workflow-revision", revision]), + Tag::parse(["continuation-sha256", &snapshot_hash]), + Tag::parse(["h", &wait.channel_id.to_string()]), + Tag::parse(["d", &hex::encode(&wait.reference)]), + Tag::parse(["workflow", &wait.workflow_id.to_string()]), + Tag::parse(["run", &wait.run_id.to_string()]), + Tag::parse(["step", &wait.step_id]), + ] + .into_iter() + .collect::, _>>() + .map_err(|e| ActionSinkError::EventBuild(e.to_string()))?; + if wait.approver_spec != "any" { + tags.push( + Tag::parse(["p", &wait.approver_spec]) + .map_err(|e| ActionSinkError::EventBuild(e.to_string()))?, + ); + } + let event = EventBuilder::new( + Kind::from(buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED as u16), + &wait.message, + ) + .tags(tags) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| ActionSinkError::EventBuild(e.to_string()))?; + let mut tx = state + .db + .begin_event_write_transaction() + .await + .map_err(|e| ActionSinkError::Database(e.to_string()))?; + buzz_deletion::store(&state.db) + .guard_transaction(&mut tx, wait.community_id) + .await + .map_err(|e| ActionSinkError::Database(e.to_string()))?; + let stored = buzz_db::workflow::approval::save_wait(&mut tx, &wait, &event) + .await + .map_err(|e| ActionSinkError::Database(e.to_string()))?; + tx.commit() + .await + .map_err(|e| ActionSinkError::Database(e.to_string()))?; + // The event and push trigger are durable before best-effort live fanout. + let _ = dispatch_persistent_event( + &tenant, + &state, + &stored, + buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED, + &event.pubkey.to_hex(), + None, + ) + .await; + Ok(()) + }) + } + fn send_message( &self, community_id: CommunityId, @@ -246,9 +339,7 @@ impl ActionSink for RelayActionSink { ) -> Pin> + Send + '_>> { let channel_id = channel_id.to_owned(); let text = text.to_owned(); - // AGENT-HOMES-001: authored_text is reserved for future mention- - // resolution in relay-signed posts; keep the parameter, silence lint. - let _authored_text = authored_text; + let authored_text = authored_text.to_owned(); let author_pubkey = author_pubkey.to_owned(); let reply_to = reply_to.map(str::to_owned); @@ -400,8 +491,9 @@ impl ActionSink for RelayActionSink { // The stored author-written template independently supplies the // authority-bearing workflow-mention tags. A trigger may therefore // render an `@Name` into visible output, but it cannot borrow the - // workflow owner's authority to wake that agent. A resolution failure - // must not drop the message, so log and proceed with the base tags. + // workflow owner's authority to wake that agent. Member or profile + // lookup failures abort the write rather than persist partial routing + // or authority metadata. let members = state .db .get_members_for_event_write(tenant.community(), channel_uuid) @@ -420,18 +512,13 @@ impl ActionSink for RelayActionSink { Some((name, nostr::PublicKey::from_slice(&u.pubkey).ok()?.to_hex())) }) .collect(); - // The owner is attributed via `actor`, never p-tagged, so they are - // not woken by their own workflow's output even if the text names - // them. Skipping them here keeps that true. - for mentioned in resolve_mention_pubkeys(&text, &named_members) { - if mentioned == author_pubkey_hex { - continue; - } - tags.push( - Tag::parse(["p", &mentioned]) - .map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?, - ); - } + append_workflow_mention_tags( + &mut tags, + &text, + &authored_text, + &named_members, + &author_pubkey_hex, + )?; let kind = Kind::from(KIND_STREAM_MESSAGE as u16); let event = EventBuilder::new(kind, &text) @@ -567,7 +654,7 @@ impl ActionSink for RelayActionSink { let channel = state .db - .get_channel(tenant.community(), channel_uuid) + .get_channel_for_event_write(tenant.community(), channel_uuid) .await .map_err(|e| match &e { buzz_db::DbError::ChannelNotFound(_) | buzz_db::DbError::NotFound(_) => { @@ -687,8 +774,8 @@ impl ActionSink for RelayActionSink { } } -/// only for targets also named in the workflow owner's stored step template. -#[allow(dead_code)] +/// Append routing tags from rendered text without adding an owner p tag, and +/// authority tags only for targets also named in the stored step template. fn append_workflow_mention_tags( tags: &mut Vec, rendered_text: &str, @@ -1332,6 +1419,14 @@ mod postgres_tests { }; let explicit = load_event(&explicit_event_id_hex).await; let injected = load_event(&injected_event_id_hex).await; + for stored in [&explicit, &injected] { + stored.event.verify().expect("persisted event signature"); + assert_eq!( + stored.event.pubkey, + state.relay_keypair.public_key(), + "workflow authority must be signed by this relay" + ); + } let tag_values = |stored: &buzz_core::StoredEvent, name: &str| -> Vec { stored @@ -1357,9 +1452,10 @@ mod postgres_tests { !p_tag_targets.iter().any(|t| t == &author_hex), "author must NOT be p-tagged — that wakes them as a second agent; got {p_tag_targets:?}" ); - assert!( - p_tag_targets.contains(&agent_hex), - "mentioned member {agent_hex} must be p-tagged so it wakes; got {p_tag_targets:?}" + assert_eq!( + p_tag_targets, + vec![agent_hex.clone()], + "only the explicitly mentioned member must be p-tagged" ); assert_eq!( tag_values(&explicit, "buzz:workflow-owner"), @@ -1373,13 +1469,20 @@ mod postgres_tests { ); let injected_p_tags = tag_values(&injected, "p"); - assert!( - injected_p_tags.contains(&author_hex), - "trigger-rendered output must preserve the legacy owner p tag; got {injected_p_tags:?}" + assert_eq!( + tag_values(&injected, "actor"), + vec![author_hex.clone()], + "trigger-rendered output must attribute its owner through actor" ); - assert!( - injected_p_tags.contains(&agent_hex), - "trigger-rendered mention must preserve legacy mention/feed routing; got {injected_p_tags:?}" + assert_eq!( + tag_values(&injected, "buzz:workflow-owner"), + vec![author_hex], + "trigger-rendered output must preserve the signed workflow owner" + ); + assert_eq!( + injected_p_tags, + vec![agent_hex], + "trigger-rendered mention must preserve routing without waking the owner" ); assert!( tag_values(&injected, "buzz:workflow-mention").is_empty(), diff --git a/crates/buzz-sdk/src/nip_oa.rs b/crates/buzz-sdk/src/nip_oa.rs index f8a994bd0c5..9a447b36234 100644 --- a/crates/buzz-sdk/src/nip_oa.rs +++ b/crates/buzz-sdk/src/nip_oa.rs @@ -319,6 +319,29 @@ pub fn verify_auth_tag_for_auth_event( Ok(owner_pubkey) } +/// Verify owner delegation for a concrete signed action, including every kind +/// and strict timestamp clause. Unlike connection admission, kind restrictions +/// are enforced here; auth-only credentials cannot authorize registration. +pub fn verify_auth_tag_for_event( + auth_tag_json: &str, + agent_pubkey: &PublicKey, + kind: u32, + created_at: u64, +) -> Result { + let owner = verify_auth_tag_for_auth_event(auth_tag_json, agent_pubkey, created_at)?; + let parsed = parse_auth_tag_fields(auth_tag_json)?; + for clause in parsed.conditions.split('&') { + if let Some(value) = clause.strip_prefix("kind=") { + if value.parse::().ok() != Some(kind) { + return Err(SdkError::InvalidInput( + "owner proof does not authorize this event kind".into(), + )); + } + } + } + Ok(owner) +} + /// Parse a NIP-OA `auth` tag JSON string into a [`Tag`] without verifying the /// signature. /// @@ -699,3 +722,35 @@ mod tests { assert!(parse_auth_tag(&bad).is_err()); } } + +#[cfg(test)] +mod machine_action_tests { + use super::*; + #[test] + fn machine_action_proof_enforces_kind_and_every_time_bound() { + let owner = Keys::generate(); + let agent = Keys::generate().public_key(); + for condition in [ + "kind=27235", + "kind=47210&kind=27235", + "created_at<100", + "created_at>100", + "kind=47210&created_at<101&created_at>100", + ] { + let proof = compute_auth_tag(&owner, &agent, condition).unwrap(); + assert!( + verify_auth_tag_for_event(&proof, &agent, 47210, 100).is_err(), + "{condition}" + ); + } + let proof = + compute_auth_tag(&owner, &agent, "kind=47210&created_at>99&created_at<101").unwrap(); + assert_eq!( + verify_auth_tag_for_event(&proof, &agent, 47210, 100).unwrap(), + owner.public_key() + ); + assert!( + verify_auth_tag_for_event(&proof, &Keys::generate().public_key(), 47210, 100).is_err() + ); + } +} diff --git a/crates/buzz-workflow/src/action_sink.rs b/crates/buzz-workflow/src/action_sink.rs index dbb44155391..7922cf1a8b2 100644 --- a/crates/buzz-workflow/src/action_sink.rs +++ b/crates/buzz-workflow/src/action_sink.rs @@ -62,6 +62,19 @@ impl From for crate::WorkflowError { /// Returns `Pin>` for dyn-compatibility — required because /// `WorkflowEngine` stores `Arc`. pub trait ActionSink: Send + Sync { + /// Sign and atomically persist the native approval request and saved wait. + /// Sinks that cannot preserve this transaction must fail closed. + fn request_approval( + &self, + _wait: buzz_db::workflow::approval::ApprovalWait, + ) -> Pin> + Send + '_>> { + Box::pin(async { + Err(ActionSinkError::InvalidInput( + "approval persistence unavailable".into(), + )) + }) + } + /// Post a message to a channel on behalf of a workflow owner. /// /// - `community_id`: the server-resolved community that owns the workflow diff --git a/crates/buzz-workflow/src/approval.rs b/crates/buzz-workflow/src/approval.rs new file mode 100644 index 00000000000..ffba15ad842 --- /dev/null +++ b/crates/buzz-workflow/src/approval.rs @@ -0,0 +1,472 @@ +//! Durable approval continuation orchestration; signed decisions remain in relay ingest. + +use std::{collections::HashMap, sync::Arc}; + +use buzz_core::CommunityId; +use buzz_db::workflow::{ + approval::{ApprovalContinuation, ApprovalWait}, + hash_approval_token, WorkflowStatus, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::{ + executor::{self, TriggerContext}, + ActionDef, WorkflowDef, WorkflowEngine, WorkflowError, +}; + +const RESUME_BUDGET_SECS: i64 = 300; + +async fn before_resume_deadline( + deadline: tokio::time::Instant, + work: F, +) -> Result { + // Tokio's timeout may poll a ready inner future before its timer. A late + // worker must not poll admission or dispatch even once. + if tokio::time::Instant::now() >= deadline { + return Err(()); + } + tokio::time::timeout_at(deadline, work) + .await + .map_err(|_| ()) +} + +#[derive(Serialize, Deserialize)] +struct Snapshot { + definition: WorkflowDef, + definition_hash: String, + owner_pubkey: String, + channel_id: Uuid, + trigger: TriggerContext, + outputs: HashMap, + next_step: usize, +} + +impl WorkflowEngine { + #[allow(clippy::too_many_arguments)] + pub(crate) async fn persist_approval_wait( + &self, + community: CommunityId, + run_id: Uuid, + def: &WorkflowDef, + trigger: &TriggerContext, + resolved: &ActionDef, + token: &str, + step_index: usize, + outputs: &HashMap, + trace: &[Value], + ) -> Result<(), WorkflowError> { + let ActionDef::RequestApproval { + from, + message, + timeout, + } = resolved + else { + return Err(WorkflowError::InvalidDefinition( + "suspended step is not an approval".into(), + )); + }; + // Approver identity is authority, never substituted from trigger text. + let Some(crate::schema::Step { + action: + ActionDef::RequestApproval { + from: authored_from, + .. + }, + .. + }) = def.steps.get(step_index) + else { + return Err(WorkflowError::InvalidDefinition( + "approval step missing".into(), + )); + }; + let spec = from.trim().to_lowercase(); + if authored_from.trim().to_lowercase() != spec + || (spec != "any" && !crate::schema::is_lowercase_hex_pubkey(&spec)) + { + return Err(WorkflowError::InvalidDefinition( + "approval requires an authored exact pubkey or any current channel member".into(), + )); + } + let timeout_secs = executor::parse_duration_secs(timeout.as_deref().unwrap_or("24h"))?; + if !(1..=604800).contains(&timeout_secs) || message.trim().is_empty() { + return Err(WorkflowError::InvalidDefinition( + "approval requires a message and a timeout between 1 second and 7 days".into(), + )); + } + let run = self.db.get_workflow_run(community, run_id).await?; + let workflow = self.db.get_workflow(community, run.workflow_id).await?; + let current_def: WorkflowDef = serde_json::from_value(workflow.definition.clone()) + .map_err(|e| WorkflowError::InvalidDefinition(e.to_string()))?; + if serde_json::to_value(¤t_def).ok() != serde_json::to_value(def).ok() + || !workflow.enabled + || workflow.status != WorkflowStatus::Active + { + return Err(WorkflowError::Unauthorized( + "workflow changed before approval suspension".into(), + )); + } + let channel_id = workflow + .channel_id + .ok_or_else(|| WorkflowError::Unauthorized("workflow has no channel".into()))?; + self.check_owner_authority(community, channel_id, &workflow.owner_pubkey, def) + .await?; + let snapshot = Snapshot { + definition: def.clone(), + definition_hash: hex::encode(&workflow.definition_hash), + owner_pubkey: hex::encode(&workflow.owner_pubkey), + channel_id, + trigger: trigger.clone(), + outputs: outputs.clone(), + next_step: step_index + 1, + }; + let reference = hash_approval_token(token); + let mut full_trace = run + .execution_trace + .as_array() + .cloned() + .ok_or_else(|| WorkflowError::Database("run trace is not an array".into()))?; + full_trace.extend_from_slice(trace); + full_trace.push(serde_json::json!({"step_id":def.steps[step_index].id,"status":"waiting_approval","approval_ref":hex::encode(&reference)})); + let wait = ApprovalWait { + community_id: community, + workflow_id: run.workflow_id, + run_id, + channel_id, + reference, + step_id: def.steps[step_index].id.clone(), + step_index: step_index as i32, + approver_spec: spec, + message: message.clone(), + timeout_secs: timeout_secs as i64, + continuation: serde_json::to_value(snapshot) + .map_err(|e| WorkflowError::Database(e.to_string()))?, + trace: Value::Array(full_trace), + }; + self.action_sink()? + .request_approval(wait) + .await + .map_err(WorkflowError::from) + } + + /// Recover at most 100 candidates per tick. Claims are durable and single-use; + /// capacity is acquired before claiming so a busy pod leaves ready work safe. + pub async fn recover_approvals(self: &Arc) -> Result<(), WorkflowError> { + for (community, reference) in self.db.workflow_approval_candidates().await? { + let Ok(permit) = Arc::clone(&self.run_semaphore).try_acquire_owned() else { + break; + }; + let deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(RESUME_BUDGET_SECS as u64); + let Some(claim) = self + .db + .claim_workflow_approval(community, &reference, RESUME_BUDGET_SECS) + .await? + else { + continue; + }; + let engine = Arc::clone(self); + tokio::spawn(async move { + let _permit = permit; + engine.resume_claim(claim, deadline).await; + }); + } + Ok(()) + } + + async fn prepare_resume( + &self, + claim: &ApprovalContinuation, + ) -> Result { + let mut snapshot: Snapshot = + serde_json::from_value(claim.snapshot.clone()).map_err(|e| { + WorkflowError::InvalidDefinition(format!("invalid approval continuation: {e}")) + })?; + let workflow = self + .db + .get_workflow(claim.community_id, claim.workflow_id) + .await?; + if snapshot.next_step != claim.next_step as usize + || !workflow.enabled + || workflow.status != WorkflowStatus::Active + || workflow.channel_id != Some(snapshot.channel_id) + || hex::encode(&workflow.owner_pubkey) != snapshot.owner_pubkey + || hex::encode(&workflow.definition_hash) != snapshot.definition_hash + { + return Err(WorkflowError::Unauthorized( + "approved workflow version or lifecycle changed".into(), + )); + } + self.check_owner_authority( + claim.community_id, + snapshot.channel_id, + &workflow.owner_pubkey, + &snapshot.definition, + ) + .await?; + if self + .db + .get_member_role( + claim.community_id, + snapshot.channel_id, + &claim.approver_pubkey, + ) + .await? + .is_none() + { + return Err(WorkflowError::Unauthorized( + "approval signer is no longer a channel member".into(), + )); + } + let approval_step = snapshot + .definition + .steps + .get(snapshot.next_step.saturating_sub(1)) + .ok_or_else(|| { + WorkflowError::InvalidDefinition("saved approval step missing".into()) + })?; + snapshot.outputs.insert(approval_step.id.clone(),serde_json::json!({"approved":true,"decision_event_id":hex::encode(&claim.decision_event_id)})); + Ok(snapshot) + } + + async fn resume_claim(&self, claim: ApprovalContinuation, deadline: tokio::time::Instant) { + // The deadline starts before the database claim, covering admission, + // spawn scheduling and execution. A late worker cannot dispatch. + let execution = before_resume_deadline(deadline, async { + let snapshot = self.prepare_resume(&claim).await.map_err(|error| { + ( + error, + crate::error::PartialProgress { + step_index: claim.next_step as usize, + trace: vec![], + }, + ) + })?; + executor::execute_steps( + self, + claim.community_id, + claim.run_id, + &snapshot.definition, + &snapshot.trigger, + snapshot.next_step, + Some(snapshot.outputs), + ) + .await + }) + .await; + // The claim reserves another 30 seconds for finalization. If storage is + // still unavailable, durable recovery records unknown without replaying. + let _ = tokio::time::timeout(std::time::Duration::from_secs(30), async { + match execution { + Ok(result) => self.finalize_run( + claim.community_id, + claim.run_id, + result, + claim.trace.as_array().cloned(), + ).await, + Err(_) => { + // A later saved approval wait is protected from this old + // finalizer; an already-issued external effect is unknown. + let _ = self.db.update_workflow_run( + claim.community_id, + claim.run_id, + buzz_db::workflow::RunStatus::Failed, + claim.next_step, + &claim.trace, + Some(buzz_db::workflow::WorkflowRunFailure { + code: "approval_resume_outcome_unknown", + message: "Continuation timed out; effects may have occurred and will not be replayed", + }), + ).await; + } + } + }).await; + } +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + use buzz_db::workflow::RunStatus; + use serde_json::json; + use std::time::Duration; + + async fn claimed_fixture() -> (buzz_db::Db, ApprovalContinuation) { + let db = buzz_db::Db::new(&buzz_db::DbConfig { + database_url: std::env::var("BUZZ_TEST_DATABASE_URL").expect("isolated database"), + max_connections: 1, + min_connections: 1, + acquire_timeout_secs: 5, + ..Default::default() + }) + .await + .expect("real Postgres pool"); + let owner = nostr::Keys::generate().public_key().to_bytes(); + let host = format!("resume-deadline-{}.example", Uuid::new_v4()); + let community = db + .ensure_configured_community(&host) + .await + .expect("community") + .id; + db.ensure_user(community, &owner).await.expect("owner"); + let channel = db + .create_channel( + community, + "deadline", + buzz_core::channel::ChannelType::Stream, + buzz_core::channel::ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .expect("channel") + .id; + let definition: WorkflowDef = serde_json::from_value(json!({ + "name":"deadline", "trigger":{"on":"webhook"}, "enabled":true, + "steps":[{"id":"review","action":"request_approval","from":"any","message":"approve"}, + {"id":"after","action":"delay","duration":"0s"}], + })) + .expect("definition"); + let definition_hash = vec![42; 32]; + let workflow = db + .create_workflow( + community, + Some(channel), + &owner, + "deadline", + &serde_json::to_string(&definition).expect("definition JSON"), + &definition_hash, + ) + .await + .expect("workflow"); + let run = db + .create_workflow_run(community, workflow, None, None) + .await + .expect("run"); + db.update_workflow_run(community, run, RunStatus::Running, 1, &json!([]), None) + .await + .expect("admitted run"); + let claim = ApprovalContinuation { + community_id: community, + reference: vec![1; 32], + run_id: run, + workflow_id: workflow, + snapshot: serde_json::to_value(Snapshot { + definition, + definition_hash: hex::encode(definition_hash), + owner_pubkey: hex::encode(owner), + channel_id: channel, + trigger: TriggerContext::default(), + outputs: HashMap::new(), + next_step: 1, + }) + .expect("snapshot"), + trace: json!([]), + next_step: 1, + approver_pubkey: owner.to_vec(), + decision_event_id: vec![2; 32], + }; + (db, claim) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_approval_resume_deadline_includes_blocked_admission() { + let (db, claim) = claimed_fixture().await; + let community = claim.community_id; + let run = claim.run_id; + // Occupy the sole real database connection while resume tries to read + // its workflow. Releasing it after the budget must not admit steps. + let held = db + .begin_event_write_transaction() + .await + .expect("occupy pool"); + let engine = Arc::new(WorkflowEngine::new( + db.clone(), + crate::WorkflowConfig::default(), + )); + let worker = tokio::spawn(async move { + engine + .resume_claim( + claim, + tokio::time::Instant::now() + Duration::from_millis(25), + ) + .await; + }); + tokio::time::sleep(Duration::from_millis(200)).await; + held.rollback().await.expect("release connection"); + tokio::time::timeout(Duration::from_secs(3), worker) + .await + .expect("bounded resume") + .expect("worker"); + let outcome = db + .get_workflow_run(community, run) + .await + .expect("durable outcome"); + assert_eq!(outcome.status, RunStatus::Failed); + assert_eq!( + outcome.error_code.as_deref(), + Some("approval_resume_outcome_unknown") + ); + assert_eq!( + outcome.current_step, 1, + "no post-approval step was admitted" + ); + assert_eq!(outcome.execution_trace, json!([])); + } + + #[tokio::test] + async fn workflow_approval_elapsed_deadline_never_polls_ready_work() { + let polled = std::sync::atomic::AtomicBool::new(false); + let result = before_resume_deadline(tokio::time::Instant::now(), async { + polled.store(true, std::sync::atomic::Ordering::SeqCst); + }) + .await; + assert!(result.is_err()); + assert!( + !polled.load(std::sync::atomic::Ordering::SeqCst), + "expired worker polled work" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_approval_spawn_delay_cannot_restart_resume_budget() { + let (db, claim) = claimed_fixture().await; + let community = claim.community_id; + let run = claim.run_id; + let engine = Arc::new(WorkflowEngine::new( + db.clone(), + crate::WorkflowConfig::default(), + )); + let deadline = tokio::time::Instant::now() + Duration::from_millis(25); + let (release, delayed) = tokio::sync::oneshot::channel(); + let worker = tokio::spawn(async move { + delayed.await.expect("delayed worker released"); + engine.resume_claim(claim, deadline).await; + }); + tokio::time::sleep(Duration::from_millis(100)).await; + release.send(()).expect("release worker past deadline"); + tokio::time::timeout(Duration::from_secs(3), worker) + .await + .expect("bounded late worker") + .expect("worker"); + let outcome = db + .get_workflow_run(community, run) + .await + .expect("durable outcome"); + assert_eq!(outcome.status, RunStatus::Failed); + assert_eq!( + outcome.error_code.as_deref(), + Some("approval_resume_outcome_unknown") + ); + assert_eq!(outcome.current_step, 1); + assert_eq!( + outcome.execution_trace, + json!([]), + "late worker must not execute any step" + ); + } +} diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index dab8d012070..c2491dc17e1 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -6,8 +6,7 @@ //! - Sequential step dispatch //! - Execution trace updates in DB //! -//! Action dispatch uses placeholder implementations that log intent. -//! Real event emission is wired in WF-07/08 (relay integration). +//! Relay-owned action sinks persist native events and approval waits. use std::collections::HashMap; @@ -844,8 +843,7 @@ pub async fn dispatch_action( let token = generate_approval_token(run_id, step_id); - // TODO (WF-08): create approval record in DB, emit kind:46010. - // For now, return Suspended with the token so the caller can persist state. + // execute_steps commits the wait before returning suspension. Ok(StepResult::Suspended { approval_token: token, @@ -1137,7 +1135,7 @@ async fn add_reaction_impl(message_id: &str, emoji: &str) -> Result)" ); - // Return the token and current state so the caller can persist the - // approval record and update the run's execution trace. + if let Err(error) = engine + .persist_approval_wait( + community_id, + run_id, + def, + trigger_ctx, + &resolved_action, + &approval_token, + i, + &step_outputs, + &trace, + ) + .await + { + return Err(( + error, + crate::error::PartialProgress { + step_index: i, + trace, + }, + )); + } return Ok(ExecutionResult { approval_token: Some(approval_token), step_index: i, diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index ee1c7467762..f95e098b649 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -31,6 +31,7 @@ //! ``` pub mod action_sink; +mod approval; pub mod error; pub mod executor; pub mod schema; @@ -204,9 +205,9 @@ impl WorkflowEngine { /// Finalize a workflow run after execution completes or fails. /// - /// This is the **single** place that maps an executor result to a DB status - /// update. All execution paths (event-triggered, manual trigger/webhook, - /// approval resume) call this instead of duplicating the 3-way match. + /// Event-triggered, manual/webhook, and resumed executions use this finalizer. + /// Suspension is already committed atomically by the executor; continuation + /// timeout/recovery separately records an explicit unknown outcome. /// /// `existing_trace` is prepended to the executor's trace — used by the /// approval-resume path where pre-approval steps already have trace entries. @@ -227,33 +228,8 @@ impl WorkflowEngine { let step_count = result.step_index as i32; if result.approval_token.is_some() { - // Approval gates are not yet implemented (WF-08). - // Fail explicitly rather than creating unreachable WaitingApproval rows. - tracing::warn!( - run_id = %run_id, - step_index = result.step_index, - "Workflow hit approval gate — not yet implemented, marking as failed" - ); - if let Err(e) = self - .db - .update_workflow_run( - community_id, - run_id, - RunStatus::Failed, - step_count, - &trace_json, - Some(buzz_db::workflow::WorkflowRunFailure { - code: "approval_not_supported", - message: "approval gates not yet implemented — see WF-08", - }), - ) - .await - { - tracing::error!( - run_id = %run_id, - "Failed to update run to Failed (approval gate): {e}" - ); - } + // The executor committed the request event, immutable continuation, + // approval and waiting run atomically before returning suspension. } else { tracing::info!(run_id = %run_id, "Workflow run completed"); if let Err(e) = self @@ -488,9 +464,19 @@ impl WorkflowEngine { /// within an interval. pub async fn run(self: &Arc) { tracing::info!("WorkflowEngine cron loop started (60s tick)"); - + let mut approval_tick = tokio::time::interval(std::time::Duration::from_secs(2)); + let mut cron_tick = tokio::time::interval(std::time::Duration::from_secs(60)); + cron_tick.tick().await; // retain the existing delayed first cron fire loop { - tokio::time::sleep(std::time::Duration::from_secs(60)).await; + tokio::select! { + _ = approval_tick.tick() => { + if let Err(error) = self.recover_approvals().await { + tracing::error!("Workflow approval recovery failed: {error}"); + } + continue; + } + _ = cron_tick.tick() => {} + } let now = Utc::now(); diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs index 7d0ba02bf9b..cfb39a83cc8 100644 --- a/crates/buzz-workflow/src/schema.rs +++ b/crates/buzz-workflow/src/schema.rs @@ -139,7 +139,7 @@ pub enum ActionDef { }, /// Suspend execution and request approval. RequestApproval { - /// User mention or role (e.g. `"@release-manager"`). + /// Exact hex pubkey or `"any"` current channel member; never a display name. from: String, /// Message shown to the approver. message: String, @@ -319,6 +319,21 @@ impl WorkflowDef { /// as a runtime failure — for `assign_agent` this is the identity-safety line: /// a workflow that mistypes an agent pubkey should never save. pub(crate) fn validate_action(step_id: &str, action: &ActionDef) -> Result<(), WorkflowError> { + if let ActionDef::RequestApproval { + from, + message, + timeout, + } = action + { + let spec = from.trim().to_lowercase(); + let duration = crate::executor::parse_duration_secs(timeout.as_deref().unwrap_or("24h"))?; + if (spec != "any" && !is_lowercase_hex_pubkey(&spec)) + || message.trim().is_empty() + || !(1..=604800).contains(&duration) + { + return Err(WorkflowError::InvalidDefinition(format!("request_approval step '{step_id}' requires an exact pubkey or any, a message, and a timeout between 1 second and 7 days"))); + } + } if let ActionDef::AssignAgent { agent_pubkey, text, @@ -508,6 +523,38 @@ mod tests { assert!(def.steps[1].if_expr.is_some()); } + #[test] + fn approval_definition_requires_stable_identity_and_bounded_wait() { + for (from, message, timeout) in [ + ("@manager", "approve", "1h"), + ("{{trigger.author}}", "approve", "1h"), + ("any", "", "1h"), + ("any", "approve", "0s"), + ("any", "approve", "169h"), + ] { + let action = ActionDef::RequestApproval { + from: from.into(), + message: message.into(), + timeout: Some(timeout.into()), + }; + assert!( + validate_action("review", &action).is_err(), + "must reject {from}/{message}/{timeout}" + ); + } + for from in ["any".to_string(), "a".repeat(64)] { + assert!(validate_action( + "review", + &ActionDef::RequestApproval { + from, + message: "Approve {{trigger.text}}".into(), + timeout: Some("168h".into()) + } + ) + .is_ok()); + } + } + #[test] fn parse_all_action_types() { // Avoid "# in YAML values (would close r# raw strings). @@ -521,7 +568,7 @@ mod tests { " - id: topic\n action: set_channel_topic\n topic: Status active\n", " - id: react\n action: add_reaction\n emoji: white_check_mark\n", " - id: hook\n action: call_webhook\n url: https://hooks.example.com/notify\n method: POST\n", - " - id: approve\n action: request_approval\n from: '@manager'\n message: Approve?\n timeout: 4h\n", + " - id: approve\n action: request_approval\n from: any\n message: Approve?\n timeout: 4h\n", " - id: wait\n action: delay\n duration: 5m\n", " - id: assign\n action: assign_agent\n agent_pubkey: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n text: Please take this\n", ); @@ -562,7 +609,7 @@ mod tests { "name: Deploy Approval\n", "trigger:\n on: webhook\n", "steps:\n", - " - id: request\n action: request_approval\n from: '@engineering-lead'\n", + " - id: request\n action: request_approval\n from: any\n", " message: Approve deploy?\n timeout: 4h\n", " - id: notify_approved\n if: 'steps_request_output_approved == true'\n", " action: send_message\n text: Deploy approved\n", diff --git a/crates/buzz-ws-client/Cargo.toml b/crates/buzz-ws-client/Cargo.toml index 5cec925677f..47ea38471b4 100644 --- a/crates/buzz-ws-client/Cargo.toml +++ b/crates/buzz-ws-client/Cargo.toml @@ -12,6 +12,7 @@ tokio = { workspace = true } tokio-tungstenite = { workspace = true } futures-util = { workspace = true } serde_json = { workspace = true } +uuid = { workspace = true } thiserror = { workspace = true } url = { workspace = true } tracing = { workspace = true } diff --git a/crates/buzz-ws-client/src/message.rs b/crates/buzz-ws-client/src/message.rs index 3c646bc8abb..7d4772692b2 100644 --- a/crates/buzz-ws-client/src/message.rs +++ b/crates/buzz-ws-client/src/message.rs @@ -44,6 +44,14 @@ pub enum RelayMessage { /// The number of matching events. count: u64, }, + /// Buzz extension: advisory task invalidation. The relay is + /// advising the client that tasks in `channel_id` changed and it should + /// refetch through the authorized HTTP API. No task content is carried. + TasksSyncRequired { + /// UUID of the channel whose task list changed, or `None` for + /// community-wide tasks. + channel_id: Option, + }, } /// The relay's response to a published event (NIP-01 `OK` message). @@ -160,6 +168,19 @@ pub fn parse_relay_message(text: &str) -> Result { count, }) } + "BUZZ_TASKS_SYNC_REQUIRED" => { + if arr.len() != 2 { + return Err(WsClientError::UnexpectedMessage(text.to_string())); + } + let channel_id = match arr.get(1) { + Some(Value::Null) => None, + Some(Value::String(value)) if uuid::Uuid::parse_str(value).is_ok() => { + Some(value.clone()) + } + _ => return Err(WsClientError::UnexpectedMessage(text.to_string())), + }; + Ok(RelayMessage::TasksSyncRequired { channel_id }) + } other => Err(WsClientError::UnexpectedMessage(format!( "unknown message type: {other}" ))), @@ -188,3 +209,29 @@ pub fn build_auth_event( .sign_with_keys(keys) .map_err(|e| WsClientError::EventBuilder(e.to_string())) } + +#[cfg(test)] +mod task_sync_tests { + use super::*; + + #[test] + fn task_signal_is_recognized_without_changing_unknown_frame_errors() { + let channel = "00000000-0000-0000-0000-000000000001"; + assert!( + matches!(parse_relay_message(&format!(r#"["BUZZ_TASKS_SYNC_REQUIRED","{channel}"]"#)), + Ok(RelayMessage::TasksSyncRequired { channel_id }) if channel_id.as_deref() == Some(channel)) + ); + assert!(matches!( + parse_relay_message(r#"["BUZZ_TASKS_SYNC_REQUIRED",null]"#), + Ok(RelayMessage::TasksSyncRequired { channel_id: None }) + )); + assert!(matches!( + parse_relay_message(r#"["BUZZ_TASKS_SYNC_REQUIRED"]"#), + Err(WsClientError::UnexpectedMessage(_)) + )); + assert!(matches!( + parse_relay_message(r#"["UNKNOWN_FUTURE_EXTENSION"]"#), + Err(WsClientError::UnexpectedMessage(_)) + )); + } +} diff --git a/desktop/scripts/check-pubkey-truncation.mjs b/desktop/scripts/check-pubkey-truncation.mjs index d65db135454..67ce2a90910 100644 --- a/desktop/scripts/check-pubkey-truncation.mjs +++ b/desktop/scripts/check-pubkey-truncation.mjs @@ -6,8 +6,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); // Truncated pubkey prefixes are forgeable (vanity grinding), so all display -// truncation goes through the canonical `truncatePubkey` / `` — this -// guard keeps ad-hoc `pubkey.slice(0, N)` forms from fragmenting again. +// truncation goes through the canonical `truncateNpub` (identity keys — +// compact npub), `truncatePubkey` (generic hex identifiers such as event and +// blob IDs), or `` — this guard keeps ad-hoc `pubkey.slice(0, N)` +// forms from fragmenting again. const rules = [ { root: "src", diff --git a/desktop/scripts/qualify-link-preview-deadline.mjs b/desktop/scripts/qualify-link-preview-deadline.mjs new file mode 100644 index 00000000000..9fae5ca1539 --- /dev/null +++ b/desktop/scripts/qualify-link-preview-deadline.mjs @@ -0,0 +1,126 @@ +// Invoked by the isolated native Rust transport fixture, never a live relay. +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; + +const endpoint = new URL(process.argv[2]); +assert.equal(endpoint.hostname, "127.0.0.1"); +const dom = new JSDOM(""); +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, +}); +const invoked = []; +const settled = []; +let deadline; +const requests = new AbortController(); +async function boundedJson(response) { + const reader = response.body.getReader(); + let bytes = 0; + const chunks = []; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + assert.ok(bytes <= 8192, "fixture response exceeds 8192 bytes"); + chunks.push(value); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } finally { + await reader.cancel(); + reader.releaseLock(); + } +} +function assertReady(snapshot) { + assert.equal(snapshot.ready, true, "fixture did not confirm readiness"); + assert.equal( + snapshot.liveBodies, + 2, + "both slow response bodies must be live", + ); + assert.equal( + snapshot.completed, + 0, + "readiness must precede native settlement", + ); + assert.deepEqual( + [...snapshot.paths].sort(), + ["/slow-one", "/slow-two"], + "only the two slow paths may have reached the transport", + ); +} +async function invoke(command, args) { + if (command === "fetch_link_preview_metadata") invoked.push(args.href); + const response = await fetch(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ command, args }), + signal: AbortSignal.any([requests.signal, AbortSignal.timeout(3000)]), + }); + assert.equal(response.status, 200); + const result = await boundedJson(response); + if (command === "fetch_link_preview_metadata") { + settled.push({ href: args.href, error: result.error ?? null }); + } + if (result.error) throw new Error(result.error); + return result.ok; +} +dom.window.__TAURI_INTERNALS__ = { invoke }; +const { loadLinkPreviewMetadata, resetLinkPreviewMetadataCache } = await import( + "../src/shared/lib/useResolvedLinkPreviews.ts" +); +try { + const urls = ["slow-one", "slow-two", "fast"].map( + (path) => `https://scheduler-deadline.example/${path}`, + ); + const started = performance.now(); + const loads = urls.map(loadLinkPreviewMetadata); + // The fixture observes real HTTP bodies and native completions. Neither + // invocation order nor a sleep establishes that both slots are occupied. + const readiness = await invoke("fixture_wait_ready", {}); + assertReady(readiness); + assert.deepEqual(invoked, urls.slice(0, 2)); + assert.equal( + settled.length, + 0, + "third URL must be queued before any settlement", + ); + assertReady(await invoke("fixture_ready_ack", {})); + const values = await Promise.race([ + Promise.all(loads.map((load) => load.promise)), + new Promise((_, reject) => { + deadline = setTimeout( + () => + reject(new Error("native previews retained both scheduler slots")), + 2000, + ); + }), + ]); + assert.deepEqual(values.slice(0, 2), [null, null]); + assert.equal(values[2]?.title, "Fast preview"); + assert.deepEqual(invoked, urls); + assert.equal( + settled.filter((row) => row.error === "link preview operation timed out") + .length, + 2, + ); + assert.equal(settled.length, 3); + console.log( + JSON.stringify({ + result: "PASS", + nativeTimeouts: 2, + fastTitle: values[2].title, + elapsedMs: Math.round(performance.now() - started), + schedulerSlots: 2, + readinessPaths: readiness.paths, + readinessLiveBodies: readiness.liveBodies, + readinessCompleted: readiness.completed, + automaticReplay: false, + }), + ); +} finally { + clearTimeout(deadline); + requests.abort(); + resetLinkPreviewMetadataCache(); + dom.window.close(); +} diff --git a/desktop/scripts/qualify-link-preview-deadline.test.mjs b/desktop/scripts/qualify-link-preview-deadline.test.mjs new file mode 100644 index 00000000000..120bef22ba9 --- /dev/null +++ b/desktop/scripts/qualify-link-preview-deadline.test.mjs @@ -0,0 +1,163 @@ +// Copied-process protocol controls. Native deadline behavior is qualified by +// link_preview_scheduler_tests.rs, not by this synthetic response fixture. +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { once } from "node:events"; +import http from "node:http"; +import test from "node:test"; +import { promisify } from "node:util"; + +const run = promisify(execFile); +const desktop = new URL("../", import.meta.url); +const cases = [ + ["reverse arrival", true], + ["dispatch waits for readiness request", true], + ["one request never arrives", false, /fixture readiness timed out/], + ["readiness is false", false, /fixture did not confirm readiness/], + [ + "response body is no longer live", + false, + /both slow response bodies must be live/, + ], + ["readiness is stale", false, /readiness must precede native settlement/], + ["third request started early", false, /only the two slow paths/], + ["readiness refused", false, /503 !== 200/], + [ + "readiness response too large", + false, + /fixture response exceeds 8192 bytes/, + ], + ["acknowledgement refused", false, /fixture lost readiness/], + ["readiness response never completes", false, /TimeoutError/], +]; +for (const [scenario, passes, reason] of cases) { + test(scenario, { timeout: 10_000 }, async () => { + const paths = []; + const pending = new Map(); + let acknowledgements = 0; + let readinessRequests = 0; + let dispatchReady; + const dispatched = new Promise((resolve) => { + dispatchReady = resolve; + }); + const reply = (response, value) => { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(value)); + }; + const snapshot = () => ({ + ready: true, + paths: [...paths], + liveBodies: 2, + completed: 0, + }); + const server = http.createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) { + body += chunk; + assert.ok(body.length < 4096); + } + const { command, args } = JSON.parse(body); + if (command === "fetch_link_preview_metadata") { + const path = new URL(args.href).pathname; + if (path === "/fast") { + paths.push(path); + reply(response, { ok: { title: "Fast preview" } }); + return; + } + pending.set(path, response); + if (pending.size === 2) dispatchReady(); + if (scenario === "reverse arrival") { + if (pending.size === 2) paths.push("/slow-two", "/slow-one"); + } else if (path === "/slow-one") { + paths.push(path); + } + return; + } + if (command === "fixture_paths") { + reply(response, { ok: paths }); // Supports the original red control. + return; + } + if (command === "fixture_wait_ready") { + readinessRequests += 1; + await dispatched; + if (scenario !== "reverse arrival") paths.push("/slow-two"); + if (scenario === "one request never arrives") { + reply(response, { error: "fixture readiness timed out" }); + } else if (scenario === "readiness refused") { + response.statusCode = 503; + reply(response, { ok: snapshot() }); + } else if (scenario === "readiness response never completes") { + response.writeHead(200, { "content-type": "application/json" }); + response.write('{"ok":'); + } else if (scenario === "readiness response too large") { + reply(response, { ok: { ...snapshot(), padding: "x".repeat(9000) } }); + } else { + const value = snapshot(); + if (scenario === "readiness is false") value.ready = false; + if (scenario === "response body is no longer live") + value.liveBodies = 1; + if (scenario === "readiness is stale") value.completed = 1; + if (scenario === "third request started early") + value.paths.push("/fast"); + reply(response, { ok: value }); + } + return; + } + if (command === "fixture_ready_ack") { + acknowledgements += 1; + if (scenario === "acknowledgement refused") { + reply(response, { error: "fixture lost readiness" }); + return; + } + reply(response, { ok: snapshot() }); + for (const slow of pending.values()) { + reply(slow, { error: "link preview operation timed out" }); + } + return; + } + reply(response, { ok: null }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const result = await run( + process.execPath, + [ + "--import", + "./test-loader.mjs", + "--experimental-strip-types", + "./scripts/qualify-link-preview-deadline.mjs", + `http://127.0.0.1:${server.address().port}/invoke`, + ], + { cwd: desktop, timeout: 6000, maxBuffer: 32 * 1024 }, + ).then( + (output) => ({ ...output, code: 0 }), + (error) => error, + ); + assert.equal(result.code === 0, passes, result.stderr ?? result.message); + if (passes) { + assert.equal(readinessRequests, 1); + assert.equal(acknowledgements, 1); + assert.deepEqual(paths.slice(0, 2).sort(), ["/slow-one", "/slow-two"]); + assert.equal(paths[2], "/fast"); + assert.match(result.stdout, /"nativeTimeouts":2/); + } else { + assert.equal( + acknowledgements, + scenario === "acknowledgement refused" ? 1 : 0, + "invalid readiness was acknowledged", + ); + assert.equal( + result.code, + 1, + "child must fail itself, not reach outer kill timeout", + ); + assert.match(result.stderr, reason); + assert.doesNotMatch(result.stdout ?? "", /"result":"PASS"/); + } + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } + }); +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index a2b05a022e3..7548ca61349 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -210,7 +210,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -221,7 +221,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1270,6 +1270,7 @@ dependencies = [ "tokio-tungstenite 0.29.0", "tracing", "url", + "uuid", ] [[package]] @@ -1478,9 +1479,9 @@ checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -1643,7 +1644,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -2310,7 +2311,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -2488,7 +2489,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2769,7 +2770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3297,7 +3298,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -4371,14 +4372,14 @@ dependencies = [ [[package]] name = "iroh" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fca9b4b462c343ff88fc0af4096c186f939b602a0bc08723536ef2c31c93971" +checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72" dependencies = [ "backon", "blake3", "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "ctutils", "data-encoding", "derive_more", @@ -4422,9 +4423,9 @@ dependencies = [ [[package]] name = "iroh-base" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830a582cd54410dc1aa71d4786a82c3297d7b0165accd8b6dbbb3b240b48140d" +checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" dependencies = [ "curve25519-dalek 5.0.0-rc.0", "data-encoding", @@ -4441,12 +4442,12 @@ dependencies = [ [[package]] name = "iroh-dns" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a" +checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349" dependencies = [ "arc-swap", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "derive_more", "hickory-resolver", "iroh-base", @@ -4492,13 +4493,13 @@ dependencies = [ [[package]] name = "iroh-relay" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354" +checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193" dependencies = [ "blake3", "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "data-encoding", "derive_more", "getrandom 0.4.3", @@ -4531,7 +4532,6 @@ dependencies = [ "tokio-websockets", "tracing", "url", - "vergen-gitcl", "webpki-roots 1.0.8", "ws_stream_wasm", ] @@ -4706,16 +4706,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "json5" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c" -dependencies = [ - "serde", - "ucd-trie", -] - [[package]] name = "jsonptr" version = "0.6.3" @@ -5189,8 +5179,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "hex", "mesh-llm-client", @@ -5199,8 +5189,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5210,21 +5200,18 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" [[package]] name = "mesh-llm-client" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", "bytes", - "crypto_box", - "ed25519-dalek", - "hex", "httparse", "iroh", "mesh-llm-identity", @@ -5234,11 +5221,9 @@ dependencies = [ "model-artifact", "nostr-sdk", "prost 0.14.4", - "rand 0.10.2", "rustls", "serde", "serde_json", - "sha2 0.10.9", "thiserror 2.0.18", "tokio", "tracing", @@ -5247,8 +5232,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "dirs", @@ -5263,8 +5248,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -5273,20 +5258,25 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", + "chrono", "clap", "crossterm 0.28.1", + "libc", "ratatui", + "serde", "serde_json", + "tracing", + "uuid", ] [[package]] name = "mesh-llm-gpu-bench" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "serde", "serde_json", @@ -5295,8 +5285,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "serde", "serde_json", @@ -5304,8 +5294,8 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "mesh-llm-native-runtime", ] @@ -5341,8 +5331,8 @@ dependencies = [ [[package]] name = "mesh-llm-host-runtime" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "argon2", @@ -5364,7 +5354,6 @@ dependencies = [ "http-body-util", "httparse", "iroh", - "json5", "keyring", "libc", "mdns-sd", @@ -5376,6 +5365,7 @@ dependencies = [ "mesh-llm-guardrails", "mesh-llm-hf-hub", "mesh-llm-identity", + "mesh-llm-log-store", "mesh-llm-native-runtime", "mesh-llm-node", "mesh-llm-plugin", @@ -5409,7 +5399,6 @@ dependencies = [ "semver", "serde", "serde_json", - "serde_yaml", "sha2 0.10.9", "skippy-coordinator", "skippy-ffi", @@ -5429,14 +5418,16 @@ dependencies = [ "tracing-subscriber", "url", "urlencoding", + "uuid", + "windows-sys 0.61.2", "zeroize", "zip 2.4.2", ] [[package]] name = "mesh-llm-identity" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "argon2", "base64 0.22.1", @@ -5455,10 +5446,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "mesh-llm-log-store" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +dependencies = [ + "chrono", + "data-encoding", + "hex", + "mesh-llm-events", + "rusqlite", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "uuid", + "windows-sys 0.61.2", +] + [[package]] name = "mesh-llm-native-runtime" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "serde", @@ -5468,8 +5477,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "mesh-llm-types", @@ -5482,8 +5491,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "async-trait", @@ -5499,8 +5508,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "dirs", @@ -5518,8 +5527,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "hex", @@ -5531,8 +5540,8 @@ dependencies = [ [[package]] name = "mesh-llm-release-footer" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "hex", "sha2 0.10.9", @@ -5540,16 +5549,19 @@ dependencies = [ [[package]] name = "mesh-llm-routing" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ + "blake3", "iroh", + "serde", + "serde_json", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "dirs", @@ -5571,8 +5583,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5586,8 +5598,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "dirs", @@ -5597,8 +5609,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "chrono", @@ -5624,8 +5636,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "hex", "serde", @@ -5635,13 +5647,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" [[package]] name = "mesh-mixture-of-agents" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5654,18 +5666,19 @@ dependencies = [ [[package]] name = "mesh-native-serving-plugin-api" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" [[package]] name = "mesh-native-serving-plugin-host" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "libloading 0.8.9", "mesh-native-serving-plugin-api", "skippy-server", + "skippy-tokenizer", ] [[package]] @@ -5755,13 +5768,13 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "model-artifact" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "async-trait", @@ -5771,26 +5784,29 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "async-trait", "chrono", "dirs", + "libc", "mesh-llm-hf-hub", "model-artifact", "model-ref", + "rustls", "serde", "serde_json", "sha2 0.10.9", "tokio", + "tracing", ] [[package]] name = "model-package" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "bytes", @@ -5809,16 +5825,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "model-artifact", @@ -5914,7 +5930,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5950,7 +5966,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" dependencies = [ - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "derive_more", "futures-buffered", "futures-lite", @@ -6140,7 +6156,7 @@ checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" dependencies = [ "atomic-waker", "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "derive_more", "ipnet", "js-sys", @@ -6195,7 +6211,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.13.0", "cfg-if 1.0.4", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "libc", "memoffset", ] @@ -6208,7 +6224,7 @@ checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.13.0", "cfg-if 1.0.4", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "libc", ] @@ -6220,7 +6236,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.13.0", "cfg-if 1.0.4", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "libc", ] @@ -6245,12 +6261,12 @@ dependencies = [ [[package]] name = "noq" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" +checksum = "09e4bb6601fa543c110d8957813267d5a8d775a0f8fbaccf1f615d06ba9b10da" dependencies = [ "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "derive_more", "noq-proto", "noq-udp", @@ -6267,9 +6283,9 @@ dependencies = [ [[package]] name = "noq-proto" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" +checksum = "baa7b5ccd819a9c68a0d955e67a881032d09b1a17219b1f90b0997a0888e1a15" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -6295,11 +6311,11 @@ dependencies = [ [[package]] name = "noq-udp" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" +checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" dependencies = [ - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "libc", "socket2", "tracing", @@ -6427,7 +6443,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6537,7 +6553,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate 2.0.2", "proc-macro2", "quote", "syn 2.0.118", @@ -6919,19 +6935,20 @@ dependencies = [ [[package]] name = "openai-frontend" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "async-trait", "axum", "futures-core", "futures-util", + "mesh-llm-events", "mesh-llm-guardrails", "serde", "serde_json", "tokio", - "tokio-stream", "tracing", + "uuid", ] [[package]] @@ -6989,9 +7006,9 @@ dependencies = [ [[package]] name = "opentelemetry" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" dependencies = [ "futures-core", "futures-sink", @@ -7003,22 +7020,22 @@ dependencies = [ [[package]] name = "opentelemetry-http" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" dependencies = [ "async-trait", "bytes", "http", "opentelemetry", - "reqwest 0.12.28", + "reqwest 0.13.4", ] [[package]] name = "opentelemetry-otlp" -version = "0.31.1" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" +checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ "http", "opentelemetry", @@ -7026,15 +7043,15 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost 0.14.4", - "reqwest 0.12.28", + "reqwest 0.13.4", "thiserror 2.0.18", ] [[package]] name = "opentelemetry-proto" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ "base64 0.22.1", "const-hex", @@ -7042,22 +7059,22 @@ dependencies = [ "opentelemetry_sdk", "prost 0.14.4", "serde", - "serde_json", "tonic", "tonic-prost", ] [[package]] name = "opentelemetry_sdk" -version = "0.31.0" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" dependencies = [ "futures-channel", "futures-executor", "futures-util", "opentelemetry", "percent-encoding", + "portable-atomic", "rand 0.9.4", "thiserror 2.0.18", ] @@ -7131,7 +7148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -8110,7 +8127,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -8152,12 +8169,12 @@ version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8531,7 +8548,6 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", - "futures-channel", "futures-core", "futures-util", "h2", @@ -8747,12 +8763,23 @@ dependencies = [ [[package]] name = "rpassword" -version = "5.0.1" +version = "7.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc936cf8a7ea60c58f030fd36a612a48f440610214dc54bc36431f9ea0c3efb" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" dependencies = [ "libc", - "winapi", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", ] [[package]] @@ -8888,7 +8915,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8958,7 +8985,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9225,7 +9252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9710,8 +9737,8 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skippy-cache" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "blake3", @@ -9720,29 +9747,29 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "libloading 0.8.9", ] [[package]] name = "skippy-metrics" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" [[package]] name = "skippy-protocol" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "prost 0.14.4", "prost-build 0.14.4", @@ -9753,8 +9780,8 @@ dependencies = [ [[package]] name = "skippy-runtime" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "anyhow", "libc", @@ -9765,10 +9792,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "skippy-scheduler" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +dependencies = [ + "skippy-runtime", + "thiserror 2.0.18", +] + [[package]] name = "skippy-server" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "ahash", "anyhow", @@ -9779,6 +9815,7 @@ dependencies = [ "clap", "futures-util", "libc", + "mesh-llm-events", "mesh-native-serving-plugin-api", "model-artifact", "openai-frontend", @@ -9790,25 +9827,27 @@ dependencies = [ "skippy-metrics", "skippy-protocol", "skippy-runtime", + "skippy-scheduler", "skippy-tokenizer", + "skippy-topology", "socket2", "tokio", - "tokio-stream", "tonic", + "uuid", ] [[package]] name = "skippy-tokenizer" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "serde", ] [[package]] name = "skippy-topology" -version = "0.75.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +version = "0.76.0-rc9" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" dependencies = [ "serde", "serde_json", @@ -9844,7 +9883,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10869,10 +10908,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10894,7 +10933,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook 0.3.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11636,7 +11675,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11737,7 +11776,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -12000,43 +12039,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "vergen" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", - "vergen-lib", -] - -[[package]] -name = "vergen-gitcl" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", - "time", - "vergen", - "vergen-lib", -] - -[[package]] -name = "vergen-lib" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", -] - [[package]] name = "version-compare" version = "0.2.1" @@ -12588,7 +12590,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 467ca128889..aaac6480e43 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -110,15 +110,15 @@ buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" } buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" } buzz_ws_client_pkg = { package = "buzz-ws-client", path = "../../crates/buzz-ws-client" } portable-pty = "0.9" -iroh = { version = "1.0.2", optional = true } -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } +iroh = { version = "1.0.3", optional = true } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } # Model catalog + hardware survey for the Share-compute model picker (same # diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client. -mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-client", optional = true } -mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-node", optional = true } -mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-system", optional = true } -mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-events", optional = true } +mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-client", optional = true } +mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-node", optional = true } +mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-system", optional = true } +mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-events", optional = true } base64 = "0.22" sha2 = "0.11" tar = "0.4" diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index ccca7c4abfa..99dcc98145a 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1167,7 +1167,7 @@ mod tests { // Simulate the minimum supported adapter version. std::fs::write( &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.7'\nexit 0\n", + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.10.0'\nexit 0\n", ) .expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) @@ -1189,10 +1189,10 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let bin = dir.path().join("codex-acp"); - // A 1.x adapter below MIN_CODEX_ACP_VERSION must still be reinstalled. + // The observed adapter bundles Codex 0.148.x and must be upgraded. std::fs::write( &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.5'\nexit 0\n", + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.6.2'\nexit 0\n", ) .expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index ec2357b85e7..8852fcb7e01 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -11,19 +11,46 @@ use crate::{ relay::{self, relay_api_base_url_with_override, relay_ws_url_with_override}, }; -/// Encode `pubkey` as npub bech32 and truncate it for display: first 10 chars -/// + "…" + last 4 chars. Returns the full bech32 when it is 16 chars or fewer. +/// Encode `pubkey` as npub bech32 and truncate it for display: first 8 +/// chars, an ellipsis, then the last 4 chars, mirroring the frontend +/// `truncateNpub` compact policy (`first8…last4` of the whole npub string). +/// Returns the full bech32 when it is 12 chars or fewer, mirroring +/// `truncatePubkey`'s short-string threshold. fn truncated_display_name(pubkey: &PublicKey) -> Result { let bech32 = pubkey .to_bech32() .map_err(|error| format!("bech32 encode failed: {error}"))?; - Ok(if bech32.len() > 16 { - format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..]) + Ok(if bech32.len() > 12 { + format!("{}…{}", &bech32[..8], &bech32[bech32.len() - 4..]) } else { bech32 }) } +#[cfg(test)] +mod truncated_display_name_tests { + use super::truncated_display_name; + use nostr::{PublicKey, ToBech32}; + + #[test] + fn compacts_to_first_8_and_last_4_of_the_npub() { + // Vector shared with the frontend `truncateNpub` tests; the expected + // form is derived from the encoded npub, not hardcoded, so the test + // asserts the compaction policy rather than one key's string. + let hex = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; + let pubkey = PublicKey::from_hex(hex).unwrap(); + let npub = pubkey.to_bech32().unwrap(); + let expected = format!("{}…{}", &npub[..8], &npub[npub.len() - 4..]); + assert_eq!(truncated_display_name(&pubkey).unwrap(), expected); + // 13 characters, matching the frontend compact form (the ellipsis is + // one char but three UTF-8 bytes, so count chars, not bytes). + assert_eq!(expected.chars().count(), 13); + assert!(expected.starts_with("npub1")); + // The compact form must not carry the raw hex key. + assert!(!expected.contains(hex)); + } +} + #[tauri::command] pub fn get_identity(state: State<'_, AppState>) -> Result { let keys = state.keys.lock().map_err(|error| error.to_string())?; diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index b781f8d9e68..1d87fd43d92 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -10,6 +10,8 @@ use reqwest::{ use serde::Serialize; use url::Url; +#[path = "link_preview_cancellation.rs"] +mod cancellation; #[path = "link_preview_image_retry.rs"] mod image_retry; #[path = "link_preview_rate_limit.rs"] @@ -17,15 +19,20 @@ mod rate_limit; #[path = "link_preview_youtube.rs"] mod youtube; -use rate_limit::{image_host_cooldown_remaining, retry_after_duration, set_image_host_cooldown}; +use rate_limit::{ + image_host_cooldown_remaining, image_host_gate, retry_after_duration, set_image_host_cooldown, +}; const MAX_PREVIEW_FETCH_BYTES: usize = 256 * 1024; const MAX_IMAGE_FETCH_BYTES: usize = 2 * 1024 * 1024; const MAX_IMAGE_DIMENSION: u32 = 4096; const MAX_IMAGE_PIXELS: u64 = 16_000_000; const MAX_SANITIZED_DIMENSION: u32 = 1200; -const PREVIEW_FETCH_TIMEOUT: Duration = Duration::from_secs(4); -const PREVIEW_TOTAL_TIMEOUT: Duration = Duration::from_secs(10); +const PREVIEW_OPERATION_TIMEOUT: Duration = Duration::from_secs(60); +const TRANSPORT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const TRANSPORT_IDLE_TIMEOUT: Duration = Duration::from_secs(30); +const DNS_RESOLUTION_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_INLINE_IMAGE_COOLDOWN: Duration = Duration::from_secs(30); const MAX_REDIRECTS: usize = 3; const MAX_METADATA_CHARS: usize = 180; const MAX_METADATA_DESCRIPTION_CHARS: usize = 280; @@ -55,16 +62,44 @@ pub struct LinkPreviewMetadata { #[tauri::command] pub async fn fetch_link_preview_metadata( href: String, + request_id: Option, ) -> Result, String> { - tokio::time::timeout( - PREVIEW_TOTAL_TIMEOUT, - fetch_link_preview_metadata_inner(href), - ) + let cancellation = cancellation::begin(request_id.as_deref()); + // One budget spans DNS, redirects, bodies, image work and cooldown waits. + // A progressing response may outlive an idle timeout, but not this budget. + let timeout = PREVIEW_OPERATION_TIMEOUT; + #[cfg(test)] + let timeout = deadline_tests::operation_timeout(timeout); + let result = tokio::time::timeout(timeout, async { + match cancellation { + Some(cancellation) => { + tokio::select! { + result = fetch_link_preview_metadata_for_url(href) => result, + () = cancellation.cancelled() => Err("link preview request cancelled".to_string()), + } + } + None => fetch_link_preview_metadata_for_url(href).await, + } + }) .await - .map_err(|_| "link preview request timed out".to_string())? + .unwrap_or_else(|_| Err("link preview operation timed out".to_string())); + cancellation::finish(request_id.as_deref()); + result +} + +/// Cancel renderer-owned metadata work, including an in-flight response body. +#[tauri::command] +pub fn cancel_link_preview_metadata(request_id: String) { + cancellation::cancel(&request_id); } -async fn fetch_link_preview_metadata_inner( +/// Release a renderer's cancellation record after its invocation settles. +#[tauri::command] +pub fn release_link_preview_metadata(request_id: String) { + cancellation::finish(Some(&request_id)); +} + +async fn fetch_link_preview_metadata_for_url( href: String, ) -> Result, String> { let mut url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; @@ -107,28 +142,15 @@ async fn fetch_link_preview_metadata_inner( let (image_result, favicon_result) = tokio::join!( async { match image_url { - Some(image_url) => Some( - tokio::time::timeout( - PREVIEW_FETCH_TIMEOUT, - fetch_sanitized_image_with_retry(image_url, false), - ) - .await - .unwrap_or(Err(ImageFetchError::Transient { - retry_after: None, - retry_inline: false, - })), - ), + Some(image_url) => { + Some(fetch_sanitized_image_with_retry(image_url, false).await) + } None => None, } }, async { match favicon_url { - Some(favicon_url) => tokio::time::timeout( - PREVIEW_FETCH_TIMEOUT, - fetch_sanitized_image(favicon_url, true), - ) - .await - .ok(), + Some(favicon_url) => Some(fetch_sanitized_image(favicon_url, true).await), None => None, } } @@ -167,6 +189,11 @@ fn apply_image_result( } async fn validate_public_https_url(url: &Url) -> Result<(), String> { + #[cfg(test)] + if METADATA_TEST_SERVER.try_with(|_| ()).is_ok() { + return Ok(()); + } + if url.scheme() != "https" || url.username() != "" || url.password().is_some() { return Err("link previews require an HTTPS URL without credentials".to_string()); } @@ -182,11 +209,15 @@ async fn validate_public_https_url(url: &Url) -> Result<(), String> { async fn resolve_public_addresses(host: &str) -> Result, String> { let host = host.to_string(); - let addresses = tokio::net::lookup_host((host.as_str(), 443)) - .await - .map_err(|error| format!("link preview DNS resolution failed: {error}"))? - .map(|address| address.ip()) - .collect::>(); + let addresses = tokio::time::timeout( + DNS_RESOLUTION_TIMEOUT, + tokio::net::lookup_host((host.as_str(), 443)), + ) + .await + .map_err(|_| "link preview DNS resolution timed out".to_string())? + .map_err(|error| format!("link preview DNS resolution failed: {error}"))? + .map(|address| address.ip()) + .collect::>(); if addresses.is_empty() { return Err("link preview DNS resolution returned no addresses".to_string()); @@ -198,7 +229,17 @@ async fn resolve_public_addresses(host: &str) -> Result, String> { Ok(addresses) } +#[cfg(test)] +tokio::task_local! { + static METADATA_TEST_SERVER: std::net::SocketAddr; +} + async fn send_pinned_request(url: &Url, accept: &str) -> Result { + #[cfg(test)] + if let Ok(address) = METADATA_TEST_SERVER.try_with(|address| *address) { + return deadline_tests::send_request(address, url, accept).await; + } + let host = url .host_str() .ok_or_else(|| "link preview URL has no host".to_string())?; @@ -211,6 +252,8 @@ async fn send_pinned_request(url: &Url, accept: &str) -> Result Result bool { + if *waited_for_cooldown { + return false; + } + *waited_for_cooldown = true; + tokio::time::sleep(retry_after).await; + true +} + +fn retryable_image_cooldown( + url: &Url, + retry_after: Option, + waited_for_cooldown: &mut bool, +) -> Option { + let retry_after = retry_after?; + // Preserve a server's renewed backoff even after this request has used + // its one inline wait. Queued requests must observe the newer cooldown. + set_image_host_cooldown(url, retry_after); + if *waited_for_cooldown { + return None; + } + if retry_after > MAX_INLINE_IMAGE_COOLDOWN { + return None; + } + *waited_for_cooldown = true; + Some(retry_after) +} + async fn fetch_sanitized_image( - mut url: Url, + url: Url, preserve_transparency: bool, ) -> Result<(String, String), ImageFetchError> { - validate_public_https_url(&url) + fetch_sanitized_image_using( + url, + preserve_transparency, + |url| async move { validate_public_https_url(&url).await }, + |url, accept| async move { send_pinned_request(&url, accept).await }, + ) + .await +} + +async fn fetch_sanitized_image_using( + mut url: Url, + preserve_transparency: bool, + mut validate_url: V, + mut send_request: F, +) -> Result<(String, String), ImageFetchError> +where + V: FnMut(Url) -> VFut, + VFut: std::future::Future>, + F: FnMut(Url, &'static str) -> Fut, + Fut: std::future::Future>, +{ + validate_url(url.clone()) .await .map_err(|_| ImageFetchError::Rejected)?; - for redirect_count in 0..=MAX_REDIRECTS { + let mut redirect_count = 0; + let mut waited_for_cooldown = false; + while redirect_count <= MAX_REDIRECTS { if let Some(retry_after) = image_host_cooldown_remaining(&url) { - return Err(ImageFetchError::Transient { - retry_after: Some(retry_after), - retry_inline: false, - }); + if retry_after > MAX_INLINE_IMAGE_COOLDOWN + || !wait_for_image_host_cooldown(&mut waited_for_cooldown, retry_after).await + { + return Err(ImageFetchError::Transient { + retry_after: Some(retry_after), + retry_inline: false, + }); + } + continue; } - let response = send_pinned_request(&url, "image/jpeg,image/png,image/webp") + + let host_gate = image_host_gate(&url); + let host_guard = host_gate.lock().await; + if image_host_cooldown_remaining(&url).is_some() { + continue; + } + let response = send_request(url.clone(), "image/jpeg,image/png,image/webp") .await .map_err(|_| ImageFetchError::Transient { retry_after: None, - retry_inline: true, + retry_inline: !waited_for_cooldown, })?; if response.status().is_redirection() { if redirect_count == MAX_REDIRECTS { @@ -370,9 +478,10 @@ async fn fetch_sanitized_image( .and_then(|value| value.to_str().ok()) .ok_or(ImageFetchError::Rejected)?; url = url.join(location).map_err(|_| ImageFetchError::Rejected)?; - validate_public_https_url(&url) + validate_url(url.clone()) .await .map_err(|_| ImageFetchError::Rejected)?; + redirect_count += 1; continue; } if !response.status().is_success() { @@ -383,12 +492,18 @@ async fn fetch_sanitized_image( || status.is_server_error() { let retry_after = retry_after_duration(&response); - if let Some(retry_after) = retry_after { - set_image_host_cooldown(&url, retry_after); + if let Some(retry_after) = + retryable_image_cooldown(&url, retry_after, &mut waited_for_cooldown) + { + drop(host_guard); + tokio::time::sleep(retry_after).await; + continue; } return Err(ImageFetchError::Transient { retry_after, - retry_inline: status != reqwest::StatusCode::TOO_MANY_REQUESTS, + retry_inline: retry_after.is_none() + && status != reqwest::StatusCode::TOO_MANY_REQUESTS + && !waited_for_cooldown, }); } return Err(ImageFetchError::Rejected); @@ -677,315 +792,13 @@ fn decode_html_entities(value: &str) -> String { } #[cfg(test)] -mod tests { - use super::rate_limit::MAX_IMAGE_RETRY_AFTER; - use super::{ - apply_image_result, declares_animation, extract_favicon_url, extract_image_url, - extract_link_preview_metadata, is_html_response, read_bytes_prefix, retry_after_duration, - sanitize_image, ImageFetchError, LinkPreviewImageFetchState, LinkPreviewMetadata, - MAX_METADATA_DESCRIPTION_CHARS, - }; - use axum::{body::Body, http::Response, routing::get, Router}; - use base64::Engine as _; - use bytes::Bytes; - use futures_util::stream; - use image::{DynamicImage, ImageFormat, Rgb, RgbImage, Rgba, RgbaImage}; - use std::{convert::Infallible, io::Cursor}; - use url::Url; - - async fn test_response(router: Router, path: &str) -> reqwest::Response { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - reqwest::get(format!("http://{address}{path}")) - .await - .unwrap() - } +#[path = "link_preview_tests.rs"] +mod tests; - #[test] - fn metadata_prefers_open_graph_and_reads_site_name() { - let html = r#" - - - Fallback"#; - assert_eq!( - extract_link_preview_metadata(html), - Some(LinkPreviewMetadata { - title: "Rich previews & cards".to_string(), - site_name: Some("Buzz".to_string()), - description: Some("Safe & useful previews".to_string()), - image_data_url: None, - image_domain: None, - image_fetch_state: LinkPreviewImageFetchState::None, - image_retry_after_ms: None, - favicon_data_url: None, - }) - ); - } - - #[test] - fn image_results_preserve_absence_and_classify_recovery() { - let mut metadata = extract_link_preview_metadata("Preview result").unwrap(); - apply_image_result(&mut metadata, None); - assert_eq!(metadata.image_fetch_state, LinkPreviewImageFetchState::None); - - apply_image_result( - &mut metadata, - Some(Err(ImageFetchError::Transient { - retry_after: Some(std::time::Duration::from_secs(15)), - retry_inline: false, - })), - ); - assert_eq!( - metadata.image_fetch_state, - LinkPreviewImageFetchState::TransientFailure - ); - assert_eq!(metadata.image_retry_after_ms, Some(15_000)); - - apply_image_result( - &mut metadata, - Some(Ok(( - "data:image/jpeg;base64,abc".to_string(), - "images.example.com".to_string(), - ))), - ); - assert_eq!( - metadata.image_fetch_state, - LinkPreviewImageFetchState::Image - ); - assert_eq!(metadata.image_domain.as_deref(), Some("images.example.com")); - } - - #[test] - fn metadata_falls_back_to_twitter_then_title() { - assert_eq!( - extract_link_preview_metadata("") - .map(|metadata| metadata.title), - Some("Tweet title".to_string()) - ); - assert_eq!( - extract_link_preview_metadata(" Plain title ") - .map(|metadata| metadata.title), - Some("Plain title".to_string()) - ); - } - - #[test] - fn metadata_preserves_description_line_breaks() { - let html = r#" - "#; - assert_eq!( - extract_link_preview_metadata(html).and_then(|metadata| metadata.description), - Some("First paragraph.\n\nAgents:\n- One\n- Two".to_string()) - ); - } - - #[test] - fn metadata_description_supports_standard_x_posts() { - let description = "x".repeat(MAX_METADATA_DESCRIPTION_CHARS + 1); - let html = format!( - r#""# - ); - let extracted = extract_link_preview_metadata(&html) - .and_then(|metadata| metadata.description) - .unwrap(); - assert_eq!(extracted.chars().count(), MAX_METADATA_DESCRIPTION_CHARS); - } - - #[test] - fn favicon_metadata_resolves_relative_icon_links() { - let page = Url::parse("https://example.com/articles/one").unwrap(); - let html = r#" - "#; - assert_eq!( - extract_favicon_url(html, &page).unwrap().as_str(), - "https://example.com/favicon.png" - ); - } - - #[test] - fn favicon_metadata_prefers_a_supported_raster_candidate() { - let page = Url::parse("https://github.com/block/buzz").unwrap(); - let html = r#" - - "#; - assert_eq!( - extract_favicon_url(html, &page).unwrap().as_str(), - "https://assets.example/favicon.png" - ); - } - - #[test] - fn favicon_metadata_uses_touch_icon_before_unsupported_ico() { - let page = Url::parse("https://twitter.com/tellaho").unwrap(); - let html = r#" - "#; - assert_eq!( - extract_favicon_url(html, &page).unwrap().as_str(), - "https://twitter.com/apple-touch-icon.png" - ); - } - - #[test] - fn image_metadata_resolves_relative_urls_and_prefers_open_graph() { - let page = Url::parse("https://example.com/articles/one").unwrap(); - let html = r#" - "#; - assert_eq!( - extract_image_url(html, &page).unwrap().as_str(), - "https://example.com/preview.png" - ); - } - - #[tokio::test] - async fn oversized_html_uses_metadata_within_the_bounded_prefix() { - const LIMIT: usize = 256; - let metadata = r#""#; - let body = format!("{metadata}{}", "x".repeat(LIMIT)); - let response = test_response( - Router::new().route( - "/declared", - get(move || { - let body = body.clone(); - async move { - Response::builder() - .header("content-type", "text/html") - .body(Body::from(body)) - .unwrap() - } - }), - ), - "/declared", - ) - .await; - assert!(response - .content_length() - .is_some_and(|size| size > LIMIT as u64)); - assert!(is_html_response(&response)); - - let prefix = read_bytes_prefix(response, LIMIT).await.unwrap(); - assert_eq!(prefix.len(), LIMIT); - let html = String::from_utf8_lossy(&prefix); - assert_eq!( - extract_link_preview_metadata(&html).map(|metadata| metadata.title), - Some("Prefix title".to_string()) - ); - assert!(extract_image_url(&html, &Url::parse("https://example.com").unwrap()).is_some()); - } - - #[tokio::test] - async fn image_retry_after_uses_bounded_delta_seconds() { - let response = test_response( - Router::new().route( - "/rate-limited", - get(|| async { - Response::builder() - .status(429) - .header("retry-after", "900") - .body(Body::empty()) - .unwrap() - }), - ), - "/rate-limited", - ) - .await; - assert_eq!( - retry_after_duration(&response), - Some(std::time::Duration::from_secs(900)) - ); - - let response = test_response( - Router::new().route( - "/excessive", - get(|| async { - Response::builder() - .status(429) - .header("retry-after", "7200") - .body(Body::empty()) - .unwrap() - }), - ), - "/excessive", - ) - .await; - assert_eq!(retry_after_duration(&response), Some(MAX_IMAGE_RETRY_AFTER)); - } - - #[tokio::test] - async fn oversized_chunked_html_ignores_metadata_beyond_the_bounded_prefix() { - const LIMIT: usize = 256; - let response = test_response( - Router::new().route( - "/chunked", - get(|| async { - let chunks = stream::iter([ - Ok::<_, Infallible>(Bytes::from(vec![b'x'; LIMIT])), - Ok(Bytes::from_static( - br#""#, - )), - ]); - Response::builder() - .header("content-type", "text/html") - .body(Body::from_stream(chunks)) - .unwrap() - }), - ), - "/chunked", - ) - .await; - assert_eq!(response.content_length(), None); - - let prefix = read_bytes_prefix(response, LIMIT).await.unwrap(); - assert_eq!(prefix.len(), LIMIT); - let html = String::from_utf8_lossy(&prefix); - assert_eq!(extract_link_preview_metadata(&html), None); - assert_eq!( - extract_image_url(&html, &Url::parse("https://example.com").unwrap()), - None - ); - } - - #[test] - fn sanitizer_rejects_mime_mismatch_and_outputs_static_jpeg() { - let source = DynamicImage::ImageRgb8(RgbImage::from_pixel(2, 2, Rgb([10, 20, 30]))); - let mut png = Cursor::new(Vec::new()); - source.write_to(&mut png, ImageFormat::Png).unwrap(); - assert!(sanitize_image(png.get_ref(), "image/jpeg", false).is_err()); - let sanitized = sanitize_image(png.get_ref(), "image/png", false).unwrap(); - assert!(sanitized.starts_with("data:image/jpeg;base64,")); - } - - #[test] - fn favicon_sanitizer_preserves_png_transparency() { - let source = DynamicImage::ImageRgba8(RgbaImage::from_pixel(2, 2, Rgba([36, 41, 47, 0]))); - let mut png = Cursor::new(Vec::new()); - source.write_to(&mut png, ImageFormat::Png).unwrap(); - - let sanitized = sanitize_image(png.get_ref(), "image/png", true).unwrap(); - assert!(sanitized.starts_with("data:image/png;base64,")); - let encoded = sanitized.split_once(',').unwrap().1; - let bytes = base64::engine::general_purpose::STANDARD - .decode(encoded) - .unwrap(); - assert!(image::load_from_memory(&bytes).unwrap().color().has_alpha()); - } - - #[test] - fn animation_markers_are_rejected_before_decode() { - let mut apng = b"\x89PNG\r\n\x1a\n".to_vec(); - apng.extend_from_slice(b"junkacTLjunk"); - assert!(declares_animation(&apng, ImageFormat::Png)); - - let mut webp = b"RIFF\x00\x00\x00\x00WEBPVP8X\x0a\x00\x00\x00".to_vec(); - webp.push(0x02); - assert!(declares_animation(&webp, ImageFormat::WebP)); - } +#[cfg(test)] +#[path = "link_preview_deadline_tests.rs"] +mod deadline_tests; - #[test] - fn metadata_requires_a_non_empty_title() { - assert_eq!(extract_link_preview_metadata(" "), None); - assert_eq!(extract_link_preview_metadata(""), None); - } -} +#[cfg(test)] +pub(super) static LINK_PREVIEW_FIXTURE_MUTEX: std::sync::OnceLock> = + std::sync::OnceLock::new(); diff --git a/desktop/src-tauri/src/commands/link_preview_cancellation.rs b/desktop/src-tauri/src/commands/link_preview_cancellation.rs new file mode 100644 index 00000000000..38be2b3a386 --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_cancellation.rs @@ -0,0 +1,89 @@ +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; + +use tokio_util::sync::CancellationToken; + +#[derive(Default)] +struct LinkPreviewCancellations { + tokens: HashMap, +} + +impl LinkPreviewCancellations { + fn begin(&mut self, request_id: &str) -> CancellationToken { + if let Some(cancellation) = self.tokens.get(request_id).cloned() { + return cancellation; + } + let cancellation = CancellationToken::new(); + self.tokens + .insert(request_id.to_string(), cancellation.clone()); + cancellation + } + + fn cancel(&mut self, request_id: &str) { + self.tokens + .entry(request_id.to_string()) + .or_default() + .cancel(); + } + + fn finish(&mut self, request_id: &str) { + self.tokens.remove(request_id); + } +} + +static LINK_PREVIEW_CANCELLATIONS: LazyLock> = + LazyLock::new(|| Mutex::new(LinkPreviewCancellations::default())); + +pub(super) fn begin(request_id: Option<&str>) -> Option { + let request_id = request_id?; + LINK_PREVIEW_CANCELLATIONS + .lock() + .ok() + .map(|mut fetches| fetches.begin(request_id)) +} + +pub(super) fn cancel(request_id: &str) { + if let Ok(mut fetches) = LINK_PREVIEW_CANCELLATIONS.lock() { + fetches.cancel(request_id); + } +} + +pub(super) fn finish(request_id: Option<&str>) { + let Some(request_id) = request_id else { + return; + }; + if let Ok(mut fetches) = LINK_PREVIEW_CANCELLATIONS.lock() { + fetches.finish(request_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cancellation_before_begin_is_retained() { + let mut fetches = LinkPreviewCancellations::default(); + fetches.cancel("cancel-before-begin"); + + let cancellation = fetches.begin("cancel-before-begin"); + + assert!(cancellation.is_cancelled()); + fetches.finish("cancel-before-begin"); + assert!(fetches.tokens.is_empty()); + } + + #[test] + fn cancellation_reaches_active_owner() { + let mut fetches = LinkPreviewCancellations::default(); + let cancellation = fetches.begin("active-fetch"); + + fetches.cancel("active-fetch"); + + assert!(cancellation.is_cancelled()); + fetches.finish("active-fetch"); + assert!(fetches.tokens.is_empty()); + } +} diff --git a/desktop/src-tauri/src/commands/link_preview_deadline_tests.rs b/desktop/src-tauri/src/commands/link_preview_deadline_tests.rs new file mode 100644 index 00000000000..0b2e7272a94 --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_deadline_tests.rs @@ -0,0 +1,342 @@ +//! Real HTTP transport coverage for the single native operation budget. +use super::*; +use axum::{ + body::Body, + http::{Response, Uri}, + routing::get, + Router, +}; +use bytes::Bytes; +use std::{ + convert::Infallible, + net::SocketAddr, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, +}; +use tokio::{task::JoinHandle, time::Instant}; + +const TEST_BUDGET: Duration = Duration::from_millis(350); +const REDIRECT_TEST_BUDGET: Duration = Duration::from_secs(1); +tokio::task_local! { + static TEST_OPERATION_TIMEOUT: Duration; +} + +pub(super) fn operation_timeout(default: Duration) -> Duration { + TEST_OPERATION_TIMEOUT + .try_with(|duration| *duration) + .unwrap_or(default) +} + +pub(super) async fn send_request( + address: SocketAddr, + url: &Url, + accept: &str, +) -> Result { + reqwest::Client::builder() + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(TRANSPORT_CONNECT_TIMEOUT) + .read_timeout(TRANSPORT_IDLE_TIMEOUT) + .build() + .unwrap() + .get(format!("http://{address}{}", url.path())) + .header(ACCEPT, accept) + .send() + .await + .map_err(|error| format!("link preview test request failed: {error}")) +} + +#[derive(Default)] +struct Traffic { + paths: Mutex>, + chunks: AtomicUsize, + live_bodies: AtomicUsize, +} + +struct Drip { + traffic: Arc, + initial_chunks: usize, +} + +impl Drop for Drip { + fn drop(&mut self) { + self.traffic.live_bodies.fetch_sub(1, Ordering::SeqCst); + } +} + +fn drip_body(traffic: Arc) -> Body { + traffic.live_bodies.fetch_add(1, Ordering::SeqCst); + Body::from_stream(futures_util::stream::unfold( + Drip { + traffic, + initial_chunks: 3, + }, + |mut state| async move { + // Establish body progress immediately, then keep the unfinished + // response alive with a slow drip. Requiring three timer wakes in + // the image stage's remaining 130 ms made this fixture depend on + // runner scheduling, despite the operation deadline working. + if state.initial_chunks > 0 { + state.initial_chunks -= 1; + } else { + tokio::time::sleep(Duration::from_millis(20)).await; + } + state.traffic.chunks.fetch_add(1, Ordering::SeqCst); + Some((Ok::<_, Infallible>(Bytes::from_static(b" ")), state)) + }, + )) +} + +struct Server { + address: SocketAddr, + task: JoinHandle<()>, +} + +impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn server(router: Router) -> Server { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + Server { + address, + task: tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }), + } +} + +async fn fetch( + address: SocketAddr, + href: String, + id: Option, +) -> Result, String> { + fetch_with_budget(address, href, id, TEST_BUDGET).await +} + +async fn fetch_with_budget( + address: SocketAddr, + href: String, + id: Option, + budget: Duration, +) -> Result, String> { + METADATA_TEST_SERVER + .scope( + address, + TEST_OPERATION_TIMEOUT.scope(budget, fetch_link_preview_metadata(href, id)), + ) + .await +} + +async fn wait_until_bodies_drop(traffic: &Traffic) { + tokio::time::timeout(Duration::from_secs(1), async { + while traffic.live_bodies.load(Ordering::SeqCst) != 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("deadline must drop the actual response stream"); +} + +fn isolated_deadline_href(stage: &str) -> String { + // These real transport tests run concurrently. Avoid every fixed or + // collision-search host used by the neighboring link-preview fixtures so + // an unrelated held semaphore stripe cannot prevent the image request. + let reserved = [ + "user-paced.example".to_owned(), + "cancel.example".to_owned(), + "rate-limit-regression.example".to_owned(), + "transport-after-cooldown.example".to_owned(), + "bounded-cooldown.example".to_owned(), + "excessive-cooldown.example".to_owned(), + "example.com".to_owned(), + "assets.example".to_owned(), + ]; + (0..128) + .map(|index| format!("https://deadline-isolated-{stage}-{index}.example/preview")) + .find(|candidate| { + let candidate = Url::parse(candidate).unwrap(); + reserved.iter().all(|host| { + let reserved = Url::parse(&format!("https://{host}/image.png")).unwrap(); + !std::ptr::eq( + super::image_host_gate(&candidate), + super::image_host_gate(&reserved), + ) + }) + }) + .expect("a free deadline image host stripe") +} + +#[tokio::test] +async fn operation_deadline_covers_metadata_oembed_images_redirects_and_cooldown() { + let _fixture_guard = super::LINK_PREVIEW_FIXTURE_MUTEX + .get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await; + assert!(PREVIEW_OPERATION_TIMEOUT > TRANSPORT_IDLE_TIMEOUT); + assert!(PREVIEW_OPERATION_TIMEOUT <= Duration::from_secs(60)); + for stage in [ + "metadata", "oembed", "image", "favicon", "redirect", "cooldown", + ] { + let traffic = Arc::new(Traffic::default()); + let endpoint = server(Router::new().fallback(get({ + let traffic = Arc::clone(&traffic); + move |uri: Uri| { + let traffic = Arc::clone(&traffic); + async move { + let path = uri.path(); + traffic.paths.lock().unwrap().push(path.to_string()); + if stage == "redirect" { + // Spend 400 ms across two redirects, then hold the third + // response beyond the whole operation budget. The old + // 350 ms budget left only 70 ms for all transport and + // scheduling overhead before the third request. + let delay = if path == "/third" { + REDIRECT_TEST_BUDGET * 2 + } else { + Duration::from_millis(200) + }; + tokio::time::sleep(delay).await; + let next = match path { + "/preview" => "/second", + "/second" => "/third", + _ => "/last", + }; + return Response::builder() + .status(302) + .header("location", next) + .body(Body::empty()) + .unwrap(); + } + if path == "/preview" && matches!(stage, "image" | "favicon" | "cooldown") { + // The later stage receives only the remaining + // operation budget, not another full budget. + tokio::time::sleep(Duration::from_millis(220)).await; + let html = if stage == "favicon" { + "Page" + } else { + "Page" + }; + return Response::builder() + .header("content-type", "text/html") + .body(Body::from(html)) + .unwrap(); + } + if stage == "cooldown" { + return Response::builder() + .status(429) + .header("retry-after", "1") + .body(Body::empty()) + .unwrap(); + } + Response::builder() + .header( + "content-type", + match stage { + "oembed" => "application/json", + "image" | "favicon" => "image/png", + _ => "text/html", + }, + ) + .body(drip_body(traffic)) + .unwrap() + } + } + }))) + .await; + let href = if stage == "oembed" { + "https://www.youtube.com/watch?v=fixture".to_string() + } else { + isolated_deadline_href(stage) + }; + let id = format!("deadline-{stage}"); + let request_id = (stage != "oembed").then_some(id.clone()); + let prior_token = cancellation::begin(request_id.as_deref()); + let started = Instant::now(); + let budget = if stage == "redirect" { + REDIRECT_TEST_BUDGET + } else { + TEST_BUDGET + }; + let result = tokio::time::timeout( + budget + Duration::from_millis(150), + fetch_with_budget(endpoint.address, href, request_id, budget), + ) + .await + .expect("the operation exceeded its single deadline"); + assert_eq!( + result, + Err("link preview operation timed out".to_string()), + "{stage}" + ); + wait_until_bodies_drop(&traffic).await; + let paths = traffic.paths.lock().unwrap().clone(); + assert!(!paths.is_empty()); + if stage == "redirect" { + assert_eq!(paths, ["/preview", "/second", "/third"]); + } else if matches!(stage, "image" | "favicon" | "cooldown") { + assert_eq!(paths, ["/preview", "/image.png"]); + } else if stage == "oembed" { + assert_eq!(paths, ["/oembed"]); + } + if !matches!(stage, "redirect" | "cooldown") { + assert!(traffic.chunks.load(Ordering::SeqCst) >= 3, "{stage}"); + } + // A later owner of this ID must not reuse this completed request token. + let next = cancellation::begin(Some(&id)).unwrap(); + cancellation::cancel(&id); + assert!(next.is_cancelled()); + assert!(!prior_token.is_some_and(|token| token.is_cancelled())); + cancellation::finish(Some(&id)); + println!( + "PREVIEW_DEADLINE stage={stage} elapsed_ms={} paths={paths:?} live_bodies=0", + started.elapsed().as_millis() + ); + } +} + +#[tokio::test] +async fn explicit_cancellation_remains_faster_than_the_operation_deadline() { + let traffic = Arc::new(Traffic::default()); + let endpoint = server(Router::new().route( + "/preview", + get({ + let traffic = Arc::clone(&traffic); + move || { + let traffic = Arc::clone(&traffic); + async move { + Response::builder() + .header("content-type", "text/html") + .body(drip_body(traffic)) + .unwrap() + } + } + }), + )) + .await; + let id = "deadline-explicit-cancel".to_string(); + let request = tokio::spawn(fetch( + endpoint.address, + "https://cancel.example/preview".into(), + Some(id.clone()), + )); + tokio::time::timeout(TEST_BUDGET, async { + while traffic.chunks.load(Ordering::SeqCst) < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + cancel_link_preview_metadata(id); + assert_eq!( + request.await.unwrap(), + Err("link preview request cancelled".into()) + ); + wait_until_bodies_drop(&traffic).await; +} + +#[path = "link_preview_scheduler_tests.rs"] +mod scheduler; diff --git a/desktop/src-tauri/src/commands/link_preview_rate_limit.rs b/desktop/src-tauri/src/commands/link_preview_rate_limit.rs index c3f7ed7188c..46032fc5f01 100644 --- a/desktop/src-tauri/src/commands/link_preview_rate_limit.rs +++ b/desktop/src-tauri/src/commands/link_preview_rate_limit.rs @@ -1,16 +1,31 @@ use std::{ - collections::HashMap, - sync::{Mutex, OnceLock}, - time::{Duration, Instant}, + collections::{hash_map::DefaultHasher, HashMap}, + hash::{Hash, Hasher}, + sync::{LazyLock, Mutex, OnceLock}, + time::Duration, }; use reqwest::header::RETRY_AFTER; +use tokio::{sync::Mutex as AsyncMutex, time::Instant}; use url::Url; pub(super) const MAX_IMAGE_RETRY_AFTER: Duration = Duration::from_secs(60 * 60); const MAX_IMAGE_HOST_COOLDOWNS: usize = 128; +const IMAGE_HOST_GATE_COUNT: usize = 64; static IMAGE_HOST_COOLDOWNS: OnceLock>> = OnceLock::new(); +// A bounded stripe table serializes image requests by host without retaining an +// unbounded attacker-controlled hostname map. Hash collisions only make two +// unrelated hosts wait for one another; they never weaken the host boundary. +static IMAGE_HOST_GATES: LazyLock<[AsyncMutex<()>; IMAGE_HOST_GATE_COUNT]> = + LazyLock::new(|| std::array::from_fn(|_| AsyncMutex::new(()))); + +pub(super) fn image_host_gate(url: &Url) -> &'static AsyncMutex<()> { + let mut hasher = DefaultHasher::new(); + url.host_str().unwrap_or_default().hash(&mut hasher); + let index = (hasher.finish() as usize) % IMAGE_HOST_GATE_COUNT; + &IMAGE_HOST_GATES[index] +} pub(super) fn retry_after_duration(response: &reqwest::Response) -> Option { response diff --git a/desktop/src-tauri/src/commands/link_preview_scheduler_tests.rs b/desktop/src-tauri/src/commands/link_preview_scheduler_tests.rs new file mode 100644 index 00000000000..8fba60d72a1 --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_scheduler_tests.rs @@ -0,0 +1,296 @@ +//! Real native/renderer qualification with transport-observed readiness. +use super::*; +use axum::{extract::State, routing::post, Json}; +use std::sync::atomic::AtomicBool; +use tokio::sync::Notify; + +const SCHEDULER_BUDGET: Duration = Duration::from_secs(1); +const READINESS_BUDGET: Duration = Duration::from_secs(2); + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Arrival { + Normal, + Reversed, + DelayedUntilReadiness, + Missing, + ExpiredBeforeReadiness, +} + +struct Activity { + traffic: Arc, + completed: AtomicUsize, + in_flight: AtomicUsize, + changed: Notify, + dispatch_released: AtomicBool, + arrival: Arrival, +} + +impl Activity { + fn paths(&self) -> Vec { + self.traffic.paths.lock().unwrap().clone() + } + + /// Capture current liveness, not just historical arrivals. A terminal + /// native request can never satisfy readiness, even if both paths were seen. + fn snapshot(&self) -> Result, String> { + let paths = self.paths(); + let completed = self.completed.load(Ordering::SeqCst); + if completed != 0 { + return Err("fixture readiness followed native settlement".into()); + } + if paths.len() > 2 || paths.iter().any(|path| path == "/fast") { + return Err("third request reached transport before readiness".into()); + } + let live = self.traffic.live_bodies.load(Ordering::SeqCst); + let mut sorted = paths.clone(); + sorted.sort(); + Ok( + (live == 2 && sorted == ["/slow-one", "/slow-two"]).then(|| { + serde_json::json!({"ready": true, "paths": paths, + "liveBodies": live, "completed": completed}) + }), + ) + } + + async fn wait_for(&self, predicate: impl Fn() -> bool) -> Result<(), String> { + tokio::time::timeout(READINESS_BUDGET, async { + loop { + let changed = self.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if predicate() { + return; + } + changed.await; + } + }) + .await + .map_err(|_| "fixture readiness timed out".into()) + } + + async fn ready(&self) -> Result { + if self.arrival == Arrival::DelayedUntilReadiness { + // A deterministic dispatch gate: the second request cannot reach + // the transport until the renderer asks for actual readiness. + self.wait_for(|| self.paths().iter().any(|p| p == "/slow-one")) + .await?; + self.dispatch_released.store(true, Ordering::SeqCst); + self.changed.notify_waiters(); + } + if self.arrival == Arrival::ExpiredBeforeReadiness { + self.wait_for(|| self.completed.load(Ordering::SeqCst) != 0) + .await?; + } + tokio::time::timeout(READINESS_BUDGET, async { + loop { + let changed = self.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if let Some(snapshot) = self.snapshot()? { + return Ok(snapshot); + } + changed.await; + } + }) + .await + .map_err(|_| "fixture readiness timed out".to_string())? + } +} + +struct NativeCall<'a>(&'a Activity); +impl Drop for NativeCall<'_> { + fn drop(&mut self) { + self.0.in_flight.fetch_sub(1, Ordering::SeqCst); + self.0.changed.notify_waiters(); + } +} + +#[derive(Clone)] +struct BridgeState { + transport: SocketAddr, + activity: Arc, +} + +async fn native_fetch( + state: &BridgeState, + args: &serde_json::Value, +) -> Result { + state.activity.in_flight.fetch_add(1, Ordering::SeqCst); + let _call = NativeCall(&state.activity); + let href = args["href"].as_str().unwrap().to_string(); + let path = Url::parse(&href).unwrap().path().to_string(); + match (state.activity.arrival, path.as_str()) { + (Arrival::Reversed, "/slow-one") => { + state + .activity + .wait_for(|| state.activity.paths().iter().any(|p| p == "/slow-two")) + .await?; + } + (Arrival::DelayedUntilReadiness | Arrival::Missing, "/slow-two") => { + state + .activity + .wait_for(|| state.activity.dispatch_released.load(Ordering::SeqCst)) + .await?; + } + _ => {} + } + // One budget starts once per actual native operation. No readiness request + // or metadata stage resets or pauses it. + let result = METADATA_TEST_SERVER + .scope( + state.transport, + TEST_OPERATION_TIMEOUT.scope( + SCHEDULER_BUDGET, + fetch_link_preview_metadata(href, Some(args["requestId"].as_str().unwrap().into())), + ), + ) + .await; + state.activity.completed.fetch_add(1, Ordering::SeqCst); + state.activity.changed.notify_waiters(); + result.map(|value| serde_json::to_value(value).unwrap()) +} + +async fn invoke( + State(state): State, + Json(input): Json, +) -> Json { + let args = &input["args"]; + let result = match input["command"].as_str().unwrap() { + "fetch_link_preview_metadata" => native_fetch(&state, args).await, + "fixture_wait_ready" => state.activity.ready().await, + "fixture_ready_ack" => state.activity.snapshot().and_then(|value| { + value.ok_or_else(|| "fixture lost readiness before acknowledgement".into()) + }), + "release_link_preview_metadata" => { + release_link_preview_metadata(args["requestId"].as_str().unwrap().into()); + Ok(serde_json::Value::Null) + } + "cancel_link_preview_metadata" => { + cancel_link_preview_metadata(args["requestId"].as_str().unwrap().into()); + Ok(serde_json::Value::Null) + } + other => Err(format!("unexpected fixture command: {other}")), + }; + Json(match result { + Ok(value) => serde_json::json!({"ok": value}), + Err(error) => serde_json::json!({"error": error}), + }) +} + +/// Uses actual production TypeScript scheduling and native slow-drip HTTP. +/// Only the IPC boundary and SSRF-pinned destination use an owned fixture. +#[tokio::test] +#[ignore = "requires Node and installed desktop JS dependencies"] +async fn renderer_scheduler_advances_after_two_native_slow_drips() { + for arrival in [ + Arrival::Normal, + Arrival::Reversed, + Arrival::DelayedUntilReadiness, + Arrival::Missing, + Arrival::ExpiredBeforeReadiness, + ] { + let activity = Arc::new(Activity { + traffic: Arc::new(Traffic::default()), + completed: AtomicUsize::new(0), + in_flight: AtomicUsize::new(0), + changed: Notify::new(), + dispatch_released: AtomicBool::new(false), + arrival, + }); + let transport = server(Router::new().fallback(get({ + let activity = Arc::clone(&activity); + move |uri: Uri| { + let activity = Arc::clone(&activity); + async move { + activity + .traffic + .paths + .lock() + .unwrap() + .push(uri.path().into()); + let body = if uri.path() == "/fast" { + Body::from("Fast preview") + } else { + drip_body(Arc::clone(&activity.traffic)) + }; + activity.changed.notify_waiters(); + Response::builder() + .header("content-type", "text/html") + .body(body) + .unwrap() + } + } + }))) + .await; + let bridge = server( + Router::new() + .route("/invoke", post(invoke)) + .with_state(BridgeState { + transport: transport.address, + activity: Arc::clone(&activity), + }), + ) + .await; + let desktop = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap(); + let output = tokio::process::Command::new("node") + .args([ + "--import", + "./test-loader.mjs", + "--experimental-strip-types", + "./scripts/qualify-link-preview-deadline.mjs", + ]) + .arg(format!("http://{}/invoke", bridge.address)) + .current_dir(desktop) + .kill_on_drop(true) + .output(); + let output = tokio::time::timeout(Duration::from_secs(10), output) + .await + .unwrap() + .unwrap(); + let passes = !matches!(arrival, Arrival::Missing | Arrival::ExpiredBeforeReadiness); + assert_eq!( + output.status.success(), + passes, + "{arrival:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + activity + .wait_for(|| activity.in_flight.load(Ordering::SeqCst) == 0) + .await + .expect("all owned bridge calls must finish before fixture teardown"); + wait_until_bodies_drop(&activity.traffic).await; + let paths = activity.paths(); + if passes { + assert_eq!(paths.len(), 3); + assert_eq!(paths[2], "/fast"); + let mut first = paths[..2].to_vec(); + first.sort(); + assert_eq!(first, ["/slow-one", "/slow-two"]); + if arrival == Arrival::Reversed { + assert_eq!(paths[0], "/slow-two"); + } + assert!(activity.traffic.chunks.load(Ordering::SeqCst) >= 6); + assert_eq!(activity.completed.load(Ordering::SeqCst), 3); + } else { + assert!(String::from_utf8_lossy(&output.stderr) + .contains("fixture readiness followed native settlement")); + assert!(!String::from_utf8_lossy(&output.stdout).contains("\"result\":\"PASS\"")); + if arrival == Arrival::Missing { + assert!(!paths.iter().any(|path| path == "/slow-two")); + } else { + assert!(paths.iter().any(|path| path == "/slow-one")); + assert!(paths.iter().any(|path| path == "/slow-two")); + } + println!( + "PREVIEW_READINESS_DENIED {arrival:?}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + println!( + "PREVIEW_READINESS arrival={arrival:?} expected_pass={passes} paths={paths:?} live_bodies=0 in_flight=0 {}", + String::from_utf8_lossy(&output.stdout).trim() + ); + } +} diff --git a/desktop/src-tauri/src/commands/link_preview_tests.rs b/desktop/src-tauri/src/commands/link_preview_tests.rs new file mode 100644 index 00000000000..2f3d32387d1 --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_tests.rs @@ -0,0 +1,732 @@ +use super::rate_limit::MAX_IMAGE_RETRY_AFTER; +use super::{ + apply_image_result, cancel_link_preview_metadata, declares_animation, extract_favicon_url, + extract_image_url, extract_link_preview_metadata, fetch_link_preview_metadata, + fetch_sanitized_image_using, is_html_response, read_bytes_prefix, retry_after_duration, + retryable_image_cooldown, sanitize_image, ImageFetchError, LinkPreviewImageFetchState, + LinkPreviewMetadata, MAX_INLINE_IMAGE_COOLDOWN, MAX_METADATA_DESCRIPTION_CHARS, +}; +use axum::{body::Body, http::Response, routing::get, Router}; +use base64::Engine as _; +use bytes::Bytes; +use futures_util::stream; +use image::{DynamicImage, ImageFormat, Rgb, RgbImage, Rgba, RgbaImage}; +use std::{ + convert::Infallible, + io::Cursor, + sync::{Arc, Mutex}, +}; +use tokio::sync::oneshot; +use url::Url; + +async fn start_test_server(router: Router) -> std::net::SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + address +} + +async fn test_response(router: Router, path: &str) -> reqwest::Response { + let address = start_test_server(router).await; + reqwest::get(format!("http://{address}{path}")) + .await + .unwrap() +} + +#[tokio::test] +async fn metadata_pipeline_allows_useful_response_beyond_ten_seconds() { + let (request_started_tx, request_started_rx) = oneshot::channel::<()>(); + let request_started_tx = Arc::new(Mutex::new(Some(request_started_tx))); + let (release_response_tx, release_response_rx) = oneshot::channel::<()>(); + let release_response_rx = Arc::new(Mutex::new(Some(release_response_rx))); + let address = start_test_server(Router::new().route( + "/preview", + get(move || { + let request_started_tx = Arc::clone(&request_started_tx); + let release_response_rx = Arc::clone(&release_response_rx); + async move { + request_started_tx + .lock() + .unwrap() + .take() + .unwrap() + .send(()) + .unwrap(); + let release_response_rx = release_response_rx.lock().unwrap().take().unwrap(); + release_response_rx.await.unwrap(); + Response::builder() + .header("content-type", "text/html") + .body(Body::from("User-paced metadata")) + .unwrap() + } + }), + )) + .await; + let fetch = tokio::spawn(super::METADATA_TEST_SERVER.scope( + address, + fetch_link_preview_metadata("https://user-paced.example/preview".to_string(), None), + )); + + request_started_rx.await.unwrap(); + // Real socket I/O must not race the paused clock auto-advancing to an idle timer. + tokio::time::sleep(std::time::Duration::from_secs(11)).await; + assert!(!fetch.is_finished()); + + release_response_tx.send(()).unwrap(); + let metadata = fetch.await.unwrap().unwrap().unwrap(); + assert_eq!(metadata.title, "User-paced metadata"); +} + +#[tokio::test] +async fn metadata_command_cancellation_drops_an_in_flight_response() { + let (request_started_tx, request_started_rx) = oneshot::channel::<()>(); + let request_started_tx = Arc::new(Mutex::new(Some(request_started_tx))); + let (_release_response_tx, release_response_rx) = oneshot::channel::<()>(); + let release_response_rx = Arc::new(Mutex::new(Some(release_response_rx))); + let address = start_test_server(Router::new().route( + "/preview", + get(move || { + let request_started_tx = Arc::clone(&request_started_tx); + let release_response_rx = Arc::clone(&release_response_rx); + async move { + request_started_tx + .lock() + .unwrap() + .take() + .unwrap() + .send(()) + .unwrap(); + let release_response_rx = release_response_rx.lock().unwrap().take().unwrap(); + let _ = release_response_rx.await; + Response::builder() + .header("content-type", "text/html") + .body(Body::from("Too late")) + .unwrap() + } + }), + )) + .await; + let request_id = "cancel-in-flight".to_string(); + let fetch = tokio::spawn(super::METADATA_TEST_SERVER.scope( + address, + fetch_link_preview_metadata( + "https://cancel.example/preview".to_string(), + Some(request_id.clone()), + ), + )); + + request_started_rx.await.unwrap(); + cancel_link_preview_metadata(request_id); + + assert_eq!( + fetch.await.unwrap(), + Err("link preview request cancelled".to_string()) + ); +} + +#[tokio::test(start_paused = true)] +async fn first_rate_limit_and_queued_host_request_share_one_cooldown_boundary() { + let cooldown = std::time::Duration::from_secs(20); + let rate_limited_path = "/rate-limited.png"; + let success_path = "/success.png"; + let url = Url::parse(&format!( + "https://rate-limit-regression.example{rate_limited_path}" + )) + .unwrap(); + let attempts = Arc::new(Mutex::new(0)); + let collision_attempts = Arc::new(Mutex::new(0)); + let image = DynamicImage::ImageRgb8(RgbImage::from_pixel(2, 2, Rgb([10, 20, 30]))); + let mut png = Cursor::new(Vec::new()); + image.write_to(&mut png, ImageFormat::Png).unwrap(); + let image_bytes = png.into_inner(); + let server_attempts = Arc::clone(&attempts); + let address = start_test_server(Router::new().route( + "/{image}", + get( + move |axum::extract::Path(image): axum::extract::Path| { + let image_bytes = image_bytes.clone(); + let server_attempts = Arc::clone(&server_attempts); + async move { + if image == "rate-limited.png" { + let attempt = { + let mut attempts = server_attempts.lock().unwrap(); + *attempts += 1; + *attempts + }; + if attempt == 1 { + return Response::builder() + .status(429) + .header("retry-after", cooldown.as_secs()) + .body(Body::empty()) + .unwrap(); + } + } + Response::builder() + .header("content-type", "image/png") + .body(Body::from(image_bytes)) + .unwrap() + } + }, + ), + )) + .await; + let test_client = reqwest::Client::new(); + let request = move |url: Url, _accept: &'static str| { + let test_client = test_client.clone(); + async move { + test_client + .get(format!("http://{address}{}", url.path())) + .send() + .await + .map_err(|error| error.to_string()) + } + }; + let collision_request = { + let collision_attempts = Arc::clone(&collision_attempts); + let test_client = reqwest::Client::new(); + move |url: Url, _accept: &'static str| { + let collision_attempts = Arc::clone(&collision_attempts); + let test_client = test_client.clone(); + async move { + *collision_attempts.lock().unwrap() += 1; + test_client + .get(format!("http://{address}{}", url.path())) + .send() + .await + .map_err(|error| error.to_string()) + } + } + }; + let validate = |_url: Url| async { Ok(()) }; + let first = tokio::spawn(fetch_sanitized_image_using( + url.clone(), + false, + validate, + request.clone(), + )); + while super::image_host_cooldown_remaining(&url).is_none() { + tokio::task::yield_now().await; + } + assert!(!first.is_finished()); + assert_eq!(*attempts.lock().unwrap(), 1); + assert_eq!(super::image_host_cooldown_remaining(&url), Some(cooldown)); + + let colliding_url = (0..10_000) + .map(|index| { + Url::parse(&format!("https://collision-{index}.example{success_path}")).unwrap() + }) + .find(|candidate| { + std::ptr::eq( + super::image_host_gate(candidate), + super::image_host_gate(&url), + ) + }) + .expect("a different host sharing the bounded gate stripe"); + + let (collision_started_tx, collision_started_rx) = oneshot::channel(); + tokio::spawn(async move { + let collision = + fetch_sanitized_image_using(colliding_url, false, validate, collision_request); + tokio::pin!(collision); + assert!(futures_util::poll!(&mut collision).is_pending()); + collision_started_tx.send(()).ok(); + assert!(collision.await.is_ok()); + }); + collision_started_rx.await.unwrap(); + assert_eq!(*collision_attempts.lock().unwrap(), 1); + assert_eq!(*attempts.lock().unwrap(), 1); + + let queued = tokio::spawn(fetch_sanitized_image_using(url, false, validate, request)); + tokio::task::yield_now().await; + assert!(!queued.is_finished()); + assert_eq!(*attempts.lock().unwrap(), 1); + + tokio::time::advance(cooldown - std::time::Duration::from_millis(1)).await; + assert!(!first.is_finished()); + assert!(!queued.is_finished()); + assert_eq!(*attempts.lock().unwrap(), 1); + + tokio::time::advance(std::time::Duration::from_millis(1)).await; + let (first, queued) = tokio::join!(first, queued); + assert!(first.unwrap().is_ok()); + assert!(queued.unwrap().is_ok()); + assert_eq!(*attempts.lock().unwrap(), 3); +} + +#[tokio::test] +async fn renewed_rate_limit_blocks_queued_url_after_inline_wait_is_used() { + let _fixture_guard = super::LINK_PREVIEW_FIXTURE_MUTEX + .get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await; + let requests = Arc::new(Mutex::new(Vec::new())); + let server_requests = Arc::clone(&requests); + let (second_started_tx, second_started_rx) = oneshot::channel(); + let second_started_tx = Arc::new(Mutex::new(Some(second_started_tx))); + let (release_second_tx, release_second_rx) = oneshot::channel(); + let release_second_rx = Arc::new(Mutex::new(Some(release_second_rx))); + let address = start_test_server(Router::new().route( + "/{image}", + get( + move |axum::extract::Path(path): axum::extract::Path| { + let requests = Arc::clone(&server_requests); + let second_started_tx = Arc::clone(&second_started_tx); + let release_second_rx = Arc::clone(&release_second_rx); + async move { + let attempt = { + let mut requests = requests.lock().unwrap(); + requests.push(path); + requests.len() + }; + if attempt == 2 { + second_started_tx + .lock() + .unwrap() + .take() + .unwrap() + .send(()) + .unwrap(); + let release = release_second_rx.lock().unwrap().take().unwrap(); + release.await.unwrap(); + } + Response::builder() + .status(429) + .header("retry-after", if attempt == 1 { "1" } else { "300" }) + .body(Body::empty()) + .unwrap() + } + }, + ), + )) + .await; + let client = reqwest::Client::new(); + let send = move |url: Url, _accept: &'static str| { + let client = client.clone(); + async move { + client + .get(format!("http://{address}{}", url.path())) + .send() + .await + .map_err(|error| error.to_string()) + } + }; + // The desktop suite runs these real transport tests concurrently. Pick a + // host stripe that cannot block the deadline fixture's known hosts. + let reserved_hosts = [ + "deadline-image.example", + "deadline-favicon.example", + "deadline-cooldown.example", + "deadline-redirect.example", + "deadline-metadata.example", + "deadline-oembed.example", + ]; + let url = (0..128) + .map(|index| { + Url::parse(&format!( + "https://renewed-rate-limit-{index}.example/first.png" + )) + .unwrap() + }) + .find(|candidate| { + reserved_hosts.iter().all(|host| { + let reserved = Url::parse(&format!("https://{host}/image.png")).unwrap(); + !std::ptr::eq( + super::image_host_gate(candidate), + super::image_host_gate(&reserved), + ) + }) + }) + .expect("a free image host stripe"); + let first = tokio::spawn(fetch_sanitized_image_using( + url.clone(), + false, + |_url| async { Ok(()) }, + send.clone(), + )); + tokio::time::timeout(std::time::Duration::from_secs(5), second_started_rx) + .await + .unwrap() + .unwrap(); + // The first fetch has spent its one inline wait and holds the host gate + // while the server prepares its renewed rate limit. Queue a different URL. + let queued = fetch_sanitized_image_using( + url.join("second.png").unwrap(), + false, + |_url| async { Ok(()) }, + send, + ); + tokio::pin!(queued); + assert!(futures_util::poll!(&mut queued).is_pending()); + release_second_tx.send(()).unwrap(); + let (first, queued) = tokio::time::timeout(std::time::Duration::from_secs(2), async { + tokio::join!(first, queued) + }) + .await + .expect("renewed cooldown must not add another inline wait"); + assert_eq!( + first.unwrap(), + Err(ImageFetchError::Transient { + retry_after: Some(std::time::Duration::from_secs(300)), + retry_inline: false, + }) + ); + assert!(matches!(queued, Err(ImageFetchError::Transient { + retry_after: Some(remaining), retry_inline: false, + }) if remaining > std::time::Duration::from_secs(295))); + assert_eq!( + *requests.lock().unwrap(), + ["first.png", "first.png"], + "queued URL must not reach a host that renewed its cooldown" + ); +} + +#[tokio::test(start_paused = true)] +async fn transport_failure_after_cooldown_does_not_renew_wait_on_outer_retry() { + let cooldown = std::time::Duration::from_secs(20); + let url = Url::parse("https://transport-after-cooldown.example/image.png").unwrap(); + super::set_image_host_cooldown(&url, cooldown); + let attempts = Arc::new(Mutex::new(0)); + + let result = super::image_retry::retry_transient_image_fetch(|| { + let url = url.clone(); + let attempts = Arc::clone(&attempts); + async move { + fetch_sanitized_image_using( + url, + false, + |_url| async { Ok(()) }, + move |_url, _accept| { + let attempts = Arc::clone(&attempts); + async move { + *attempts.lock().unwrap() += 1; + Err("connection failed".to_string()) + } + }, + ) + .await + } + }) + .await; + + assert_eq!( + result, + Err(ImageFetchError::Transient { + retry_after: None, + retry_inline: false, + }) + ); + assert_eq!(*attempts.lock().unwrap(), 1); +} + +#[test] +fn image_cooldown_wait_is_short_and_one_shot() { + let url = Url::parse("https://bounded-cooldown.example/image.png").unwrap(); + let mut waited = false; + assert_eq!( + retryable_image_cooldown(&url, Some(MAX_INLINE_IMAGE_COOLDOWN), &mut waited,), + Some(MAX_INLINE_IMAGE_COOLDOWN) + ); + assert!(waited); + assert_eq!( + retryable_image_cooldown(&url, Some(MAX_INLINE_IMAGE_COOLDOWN), &mut waited,), + None + ); + let excessive_url = Url::parse("https://excessive-cooldown.example/image.png").unwrap(); + let mut excessive_waited = false; + assert_eq!( + retryable_image_cooldown( + &excessive_url, + Some(MAX_INLINE_IMAGE_COOLDOWN + std::time::Duration::from_secs(1)), + &mut excessive_waited, + ), + None + ); + assert!(!excessive_waited); +} + +#[test] +fn metadata_prefers_open_graph_and_reads_site_name() { + let html = r#" + + + Fallback"#; + assert_eq!( + extract_link_preview_metadata(html), + Some(LinkPreviewMetadata { + title: "Rich previews & cards".to_string(), + site_name: Some("Buzz".to_string()), + description: Some("Safe & useful previews".to_string()), + image_data_url: None, + image_domain: None, + image_fetch_state: LinkPreviewImageFetchState::None, + image_retry_after_ms: None, + favicon_data_url: None, + }) + ); +} + +#[test] +fn image_results_preserve_absence_and_classify_recovery() { + let mut metadata = extract_link_preview_metadata("Preview result").unwrap(); + apply_image_result(&mut metadata, None); + assert_eq!(metadata.image_fetch_state, LinkPreviewImageFetchState::None); + + apply_image_result( + &mut metadata, + Some(Err(ImageFetchError::Transient { + retry_after: Some(std::time::Duration::from_secs(15)), + retry_inline: false, + })), + ); + assert_eq!( + metadata.image_fetch_state, + LinkPreviewImageFetchState::TransientFailure + ); + assert_eq!(metadata.image_retry_after_ms, Some(15_000)); + + apply_image_result( + &mut metadata, + Some(Ok(( + "data:image/jpeg;base64,abc".to_string(), + "images.example.com".to_string(), + ))), + ); + assert_eq!( + metadata.image_fetch_state, + LinkPreviewImageFetchState::Image + ); + assert_eq!(metadata.image_domain.as_deref(), Some("images.example.com")); +} + +#[test] +fn metadata_falls_back_to_twitter_then_title() { + assert_eq!( + extract_link_preview_metadata("") + .map(|metadata| metadata.title), + Some("Tweet title".to_string()) + ); + assert_eq!( + extract_link_preview_metadata(" Plain title ") + .map(|metadata| metadata.title), + Some("Plain title".to_string()) + ); +} + +#[test] +fn metadata_preserves_description_line_breaks() { + let html = r#" + "#; + assert_eq!( + extract_link_preview_metadata(html).and_then(|metadata| metadata.description), + Some("First paragraph.\n\nAgents:\n- One\n- Two".to_string()) + ); +} + +#[test] +fn metadata_description_supports_standard_x_posts() { + let description = "x".repeat(MAX_METADATA_DESCRIPTION_CHARS + 1); + let html = format!( + r#""# + ); + let extracted = extract_link_preview_metadata(&html) + .and_then(|metadata| metadata.description) + .unwrap(); + assert_eq!(extracted.chars().count(), MAX_METADATA_DESCRIPTION_CHARS); +} + +#[test] +fn favicon_metadata_resolves_relative_icon_links() { + let page = Url::parse("https://example.com/articles/one").unwrap(); + let html = r#" + "#; + assert_eq!( + extract_favicon_url(html, &page).unwrap().as_str(), + "https://example.com/favicon.png" + ); +} + +#[test] +fn favicon_metadata_prefers_a_supported_raster_candidate() { + let page = Url::parse("https://github.com/block/buzz").unwrap(); + let html = r#" + + "#; + assert_eq!( + extract_favicon_url(html, &page).unwrap().as_str(), + "https://assets.example/favicon.png" + ); +} + +#[test] +fn favicon_metadata_uses_touch_icon_before_unsupported_ico() { + let page = Url::parse("https://twitter.com/tellaho").unwrap(); + let html = r#" + "#; + assert_eq!( + extract_favicon_url(html, &page).unwrap().as_str(), + "https://twitter.com/apple-touch-icon.png" + ); +} + +#[test] +fn image_metadata_resolves_relative_urls_and_prefers_open_graph() { + let page = Url::parse("https://example.com/articles/one").unwrap(); + let html = r#" + "#; + assert_eq!( + extract_image_url(html, &page).unwrap().as_str(), + "https://example.com/preview.png" + ); +} + +#[tokio::test] +async fn oversized_html_uses_metadata_within_the_bounded_prefix() { + const LIMIT: usize = 256; + let metadata = r#""#; + let body = format!("{metadata}{}", "x".repeat(LIMIT)); + let response = test_response( + Router::new().route( + "/declared", + get(move || { + let body = body.clone(); + async move { + Response::builder() + .header("content-type", "text/html") + .body(Body::from(body)) + .unwrap() + } + }), + ), + "/declared", + ) + .await; + assert!(response + .content_length() + .is_some_and(|size| size > LIMIT as u64)); + assert!(is_html_response(&response)); + + let prefix = read_bytes_prefix(response, LIMIT).await.unwrap(); + assert_eq!(prefix.len(), LIMIT); + let html = String::from_utf8_lossy(&prefix); + assert_eq!( + extract_link_preview_metadata(&html).map(|metadata| metadata.title), + Some("Prefix title".to_string()) + ); + assert!(extract_image_url(&html, &Url::parse("https://example.com").unwrap()).is_some()); +} + +#[tokio::test] +async fn image_retry_after_uses_bounded_delta_seconds() { + let response = test_response( + Router::new().route( + "/rate-limited", + get(|| async { + Response::builder() + .status(429) + .header("retry-after", "900") + .body(Body::empty()) + .unwrap() + }), + ), + "/rate-limited", + ) + .await; + assert_eq!( + retry_after_duration(&response), + Some(std::time::Duration::from_secs(900)) + ); + + let response = test_response( + Router::new().route( + "/excessive", + get(|| async { + Response::builder() + .status(429) + .header("retry-after", "7200") + .body(Body::empty()) + .unwrap() + }), + ), + "/excessive", + ) + .await; + assert_eq!(retry_after_duration(&response), Some(MAX_IMAGE_RETRY_AFTER)); +} + +#[tokio::test] +async fn oversized_chunked_html_ignores_metadata_beyond_the_bounded_prefix() { + const LIMIT: usize = 256; + let response = test_response( + Router::new().route( + "/chunked", + get(|| async { + let chunks = stream::iter([ + Ok::<_, Infallible>(Bytes::from(vec![b'x'; LIMIT])), + Ok(Bytes::from_static( + br#""#, + )), + ]); + Response::builder() + .header("content-type", "text/html") + .body(Body::from_stream(chunks)) + .unwrap() + }), + ), + "/chunked", + ) + .await; + assert_eq!(response.content_length(), None); + + let prefix = read_bytes_prefix(response, LIMIT).await.unwrap(); + assert_eq!(prefix.len(), LIMIT); + let html = String::from_utf8_lossy(&prefix); + assert_eq!(extract_link_preview_metadata(&html), None); + assert_eq!( + extract_image_url(&html, &Url::parse("https://example.com").unwrap()), + None + ); +} + +#[test] +fn sanitizer_rejects_mime_mismatch_and_outputs_static_jpeg() { + let source = DynamicImage::ImageRgb8(RgbImage::from_pixel(2, 2, Rgb([10, 20, 30]))); + let mut png = Cursor::new(Vec::new()); + source.write_to(&mut png, ImageFormat::Png).unwrap(); + assert!(sanitize_image(png.get_ref(), "image/jpeg", false).is_err()); + let sanitized = sanitize_image(png.get_ref(), "image/png", false).unwrap(); + assert!(sanitized.starts_with("data:image/jpeg;base64,")); +} + +#[test] +fn favicon_sanitizer_preserves_png_transparency() { + let source = DynamicImage::ImageRgba8(RgbaImage::from_pixel(2, 2, Rgba([36, 41, 47, 0]))); + let mut png = Cursor::new(Vec::new()); + source.write_to(&mut png, ImageFormat::Png).unwrap(); + + let sanitized = sanitize_image(png.get_ref(), "image/png", true).unwrap(); + assert!(sanitized.starts_with("data:image/png;base64,")); + let encoded = sanitized.split_once(',').unwrap().1; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + assert!(image::load_from_memory(&bytes).unwrap().color().has_alpha()); +} + +#[test] +fn animation_markers_are_rejected_before_decode() { + let mut apng = b"\x89PNG\r\n\x1a\n".to_vec(); + apng.extend_from_slice(b"junkacTLjunk"); + assert!(declares_animation(&apng, ImageFormat::Png)); + + let mut webp = b"RIFF\x00\x00\x00\x00WEBPVP8X\x0a\x00\x00\x00".to_vec(); + webp.push(0x02); + assert!(declares_animation(&webp, ImageFormat::WebP)); +} + +#[test] +fn metadata_requires_a_non_empty_title() { + assert_eq!(extract_link_preview_metadata(" "), None); + assert_eq!(extract_link_preview_metadata(""), None); +} diff --git a/desktop/src-tauri/src/commands/link_preview_youtube.rs b/desktop/src-tauri/src/commands/link_preview_youtube.rs index a0a5a753dcd..b759046d8f0 100644 --- a/desktop/src-tauri/src/commands/link_preview_youtube.rs +++ b/desktop/src-tauri/src/commands/link_preview_youtube.rs @@ -5,8 +5,8 @@ use url::Url; use super::{ apply_image_result, fetch_sanitized_image, normalize_metadata_description, - normalize_metadata_text, read_limited_bytes, send_pinned_request, ImageFetchError, - LinkPreviewImageFetchState, LinkPreviewMetadata, PREVIEW_FETCH_TIMEOUT, + normalize_metadata_text, read_limited_bytes, send_pinned_request, LinkPreviewImageFetchState, + LinkPreviewMetadata, }; const MAX_OEMBED_FETCH_BYTES: usize = 64 * 1024; @@ -54,17 +54,7 @@ pub(super) async fn fetch_oembed_metadata( return Ok(None); }; let image_result = match thumbnail_url { - Some(thumbnail_url) => Some( - tokio::time::timeout( - PREVIEW_FETCH_TIMEOUT, - fetch_sanitized_image(thumbnail_url, false), - ) - .await - .unwrap_or(Err(ImageFetchError::Transient { - retry_after: None, - retry_inline: false, - })), - ), + Some(thumbnail_url) => Some(fetch_sanitized_image(thumbnail_url, false).await), None => None, }; apply_image_result(&mut metadata, image_result); diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 418f994fb3e..c01c8473d91 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -353,7 +353,7 @@ pub async fn apply_workspace( } } }); - return Ok(()); + Ok(()) } #[cfg(not(feature = "mesh-llm"))] @@ -372,9 +372,11 @@ pub async fn apply_workspace( return Ok(()); } - assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?; - - Ok(()) + #[cfg(not(feature = "mesh-llm"))] + { + assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?; + Ok(()) + } } #[cfg(test)] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f86b3914997..2d11f81d826 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -594,6 +594,8 @@ pub fn run() { get_relay_http_url, get_media_proxy_port, fetch_link_preview_metadata, + cancel_link_preview_metadata, + release_link_preview_metadata, discover_acp_auth_methods, discover_acp_providers, discover_hermes_profiles, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 531ae335ce5..e4b87e7557a 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -783,12 +783,14 @@ pub(crate) fn classify_runtime( /// The oldest `codex-acp` version supported by Buzz managed agents. /// /// Older 1.x adapters are detected successfully, but can still bundle a Codex runtime -/// that does not reliably give `buzz` CLI subprocesses outbound relay access. +/// that cannot use newer models. Adapter 1.6.2 bundles Codex 0.148.x, which rejects +/// GPT-6 Astra even when the separately installed Codex CLI has been updated. +/// Published adapter 1.10.0 depends on `@openai/codex ^0.153.3`. /// /// Bump policy: raise this only when a newer adapter fixes a defect that breaks managed /// agents, and only to a version already published on npm — every user below the floor is /// offered a reinstall on their next discovery pass. -pub(crate) const MIN_CODEX_ACP_VERSION: (u64, u64, u64) = (1, 1, 7); +pub(crate) const MIN_CODEX_ACP_VERSION: (u64, u64, u64) = (1, 10, 0); /// Probe the full version of a `codex-acp` binary by running `--version`. /// diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index bc2141f6e13..eab61a3ce05 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -685,7 +685,7 @@ fn codex_adapter_availability_available_for_minimum_supported_binary() { let bin = dir.join("codex-acp"); std::fs::write( &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.7'\nexit 0\n", + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.10.0'\nexit 0\n", ) .expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); @@ -722,27 +722,6 @@ fn codex_adapter_availability_outdated_for_0x_binary() { ); } -#[cfg(unix)] -#[test] -fn codex_adapter_availability_outdated_for_older_1x_binary() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().expect("temp dir"); - let bin = dir.path().join("codex-acp"); - std::fs::write( - &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.5'\nexit 0\n", - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - - assert_eq!( - codex_adapter_availability(&bin), - AcpAvailabilityStatus::AdapterOutdated, - "a 1.x adapter below the floor must be offered an upgrade" - ); -} - /// The strict three-component parse fails closed: a version Buzz cannot compare /// against the floor is treated as outdated rather than assumed current. #[cfg(unix)] diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs index 82bfd27f325..19053865cbe 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs @@ -46,3 +46,30 @@ fn probe_codex_acp_version_uses_augmented_path_for_env_shebang_interpreter() { "the injected augmented PATH should allow /usr/bin/env to find node" ); } + +#[cfg(unix)] +#[test] +fn codex_adapter_availability_outdated_for_older_1x_binary() { + use super::super::{codex_adapter_availability, codex_adapter_is_outdated}; + use crate::managed_agents::AcpAvailabilityStatus; + use std::os::unix::fs::PermissionsExt; + + for version in ["1.1.5", "1.1.7", "1.6.2", "1.9.0"] { + let dir = tempfile::tempdir().expect("temp dir"); + let bin = dir.path().join("codex-acp"); + std::fs::write( + &bin, + format!("#!/bin/sh\necho '@agentclientprotocol/codex-acp {version}'\nexit 0\n"), + ) + .expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) + .expect("chmod script"); + + assert_eq!( + codex_adapter_availability(&bin), + AcpAvailabilityStatus::AdapterOutdated, + "adapter {version} must be offered an upgrade" + ); + assert!(codex_adapter_is_outdated(&bin)); + } +} diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index fb49f757801..6ff218243d0 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -511,7 +511,7 @@ fn inherited_shared_compute_translates_to_supported_agent_transport() { ); assert_eq!( effective.env.get("BUZZ_AGENT_MODEL").map(String::as_str), - Some("auto") + Some(super::super::RELAY_MESH_VIRTUAL_MODEL_ID) ); assert_eq!( effective diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 09af3f583a8..6fbed26c79d 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -69,6 +69,21 @@ pub fn apply_relay_mesh_env( // survives (see `insert_default_if_unset`, and the copy-forward list in // `relay_mesh_process_env` that preserves it through the spawn path). insert_default_if_unset(env, "BUZZ_AGENT_REQUIRE_REPLY", "1"); + // A local mesh prefills a cold multi-ten-thousand-token prompt at a few + // hundred tokens/second, so the first turn of a large session legitimately + // runs for minutes with no bytes on the wire (`stream: false`). MeshLLM's + // own frontend gives such a request 600 s before it aborts + // (`OpenAiFrontendConfig::DEFAULT_BACKEND_TIMEOUT`); buzz-agent's 240 s + // default abandons it at four minutes, so the client gives up on work the + // server is still doing and retries it — adding load to a box that is + // already prefilling. Seat the client just above the server's budget so the + // mesh's own error is what surfaces, rather than a client-side abort racing + // it. Measured: an 88,318-token cold prompt on an M5 Max returns 200 at + // 503 s. Remote providers keep the 240 s default; only mesh moves. + // A default, not policy — an explicit user value survives via + // `insert_default_if_unset` and the copy-forward list in + // `relay_mesh_process_env`. + insert_default_if_unset(env, "BUZZ_AGENT_LLM_TIMEOUT_SECS", "660"); // Deliberately no BUZZ_AGENT_THINKING_EFFORT default: mesh translates // `reasoning_effort` into the chat template's `enable_thinking` flag, so any // value we pick overrides each model's own template default — and the right @@ -105,6 +120,10 @@ pub fn relay_mesh_process_env( for key in [ "BUZZ_AGENT_MAX_OUTPUT_TOKENS", "BUZZ_AGENT_THINKING_EFFORT", + // Same reason as `BUZZ_AGENT_REQUIRE_REPLY` below: without the + // copy-forward, an explicit user timeout is re-defaulted to 660 by + // `apply_relay_mesh_env`. + "BUZZ_AGENT_LLM_TIMEOUT_SECS", // Must be copied forward for the user's value to survive: this map is // written onto the command *after* the layered user env, so a key absent // here is re-defaulted by `apply_relay_mesh_env` below and an explicit @@ -273,6 +292,74 @@ mod tests { ); } + /// MeshLLM's frontend allows a backend call 600 s + /// (`OpenAiFrontendConfig::DEFAULT_BACKEND_TIMEOUT`). buzz-agent's 240 s + /// default abandons a healthy cold prefill long before the server does and + /// retries it, so the client budget must sit above the server's. + #[test] + fn native_provider_outlasts_the_mesh_backend_timeout() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + + let seconds: u64 = env + .get("BUZZ_AGENT_LLM_TIMEOUT_SECS") + .expect("mesh seeds a client timeout") + .parse() + .expect("timeout is whole seconds"); + assert!( + seconds > 600, + "client budget ({seconds}s) must outlast the mesh frontend's 600s backend timeout" + ); + } + + #[test] + fn native_provider_preserves_explicit_llm_timeout() { + let mut env = + BTreeMap::from([("BUZZ_AGENT_LLM_TIMEOUT_SECS".to_string(), "90".to_string())]); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + + assert_eq!( + env.get("BUZZ_AGENT_LLM_TIMEOUT_SECS").map(String::as_str), + Some("90"), + "an explicit timeout is a user decision, not a value to re-default" + ); + } + + /// Same spawn-ordering hazard as the reply guard: without the copy-forward + /// in `relay_mesh_process_env`, the user's value is re-defaulted here. + #[test] + fn process_env_preserves_explicit_llm_timeout() { + let effective_env = + BTreeMap::from([("BUZZ_AGENT_LLM_TIMEOUT_SECS".to_string(), "90".to_string())]); + + let env = relay_mesh_process_env(&effective_env, "Gemma-4"); + + assert_eq!( + env.get("BUZZ_AGENT_LLM_TIMEOUT_SECS").map(String::as_str), + Some("90") + ); + } + + #[test] + fn non_mesh_provider_leaves_llm_timeout_unset() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env(&mut env, Some("anthropic"), Some("claude-haiku-4.5")); + + assert_eq!( + env.get("BUZZ_AGENT_LLM_TIMEOUT_SECS"), + None, + "remote providers keep buzz-agent's own default" + ); + } + #[test] fn non_mesh_provider_leaves_reply_guard_unset() { let mut env = BTreeMap::new(); diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 5b79ccac27f..d29bec4a784 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -545,22 +545,6 @@ pub(crate) fn spawn_pending_profile_reconciliations(app: &tauri::AppHandle, work } } -#[cfg(test)] -mod profile_reconcile_tests { - use super::profile_reconcile_completed; - use crate::commands::ProfileReconcileOutcome; - - #[test] - fn skipped_reconciliation_never_retires_pending_work() { - assert!(profile_reconcile_completed( - ProfileReconcileOutcome::Reconciled - )); - assert!(!profile_reconcile_completed( - ProfileReconcileOutcome::SkippedDisabled - )); - } -} - #[cfg(feature = "mesh-llm")] fn persist_restore_error( app: &tauri::AppHandle, @@ -578,3 +562,19 @@ fn persist_restore_error( record.last_error = Some(error); save_managed_agents(app, &records) } + +#[cfg(test)] +mod profile_reconcile_tests { + use super::profile_reconcile_completed; + use crate::commands::ProfileReconcileOutcome; + + #[test] + fn skipped_reconciliation_never_retires_pending_work() { + assert!(profile_reconcile_completed( + ProfileReconcileOutcome::Reconciled + )); + assert!(!profile_reconcile_completed( + ProfileReconcileOutcome::SkippedDisabled + )); + } +} diff --git a/desktop/src-tauri/src/mesh_llm/catalog.rs b/desktop/src-tauri/src/mesh_llm/catalog.rs index 1a11fcfcd13..eb5d0c1cf16 100644 --- a/desktop/src-tauri/src/mesh_llm/catalog.rs +++ b/desktop/src-tauri/src/mesh_llm/catalog.rs @@ -13,38 +13,50 @@ use mesh_llm_system::hardware; use mesh_llm_system::vram::{format_rated_capacity, rated_capacity_gb}; /// Buzz-curated tier picks. These are the models we know survive the agent -/// harness on shared compute — deliberately non-reasoning instruction models, -/// so agents stay snappy instead of burning hidden reasoning tokens. +/// harness on shared compute. /// -/// The large pick is resolved through mesh-llm's remote catalog -/// (huggingface.co/datasets/meshllm/catalog), so it does not need to exist in -/// the compiled `MODEL_CATALOG`; the entry is synthesized below. -const CURATED_LARGE: &str = "unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"; -const CURATED_LARGE_ALIAS: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M"; -const CURATED_LARGE_SIZE: &str = "17GB"; -const CURATED_LARGE_FILE: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf"; -const CURATED_LARGE_DESCRIPTION: &str = - "Gemma 4 26B MoE (4B active) — Buzz default for 64GB+ machines"; +/// The recommended ladder follows rated unified memory: +/// - below 32 GB: Gemma 4 E4B; +/// - 32 GB through the rated classes below 64 GB: Qwen3.5 9B; +/// - 64 GB and above: Qwen3.8 27B. +/// +/// The Qwen entries are canonicalized from mesh-llm's compiled +/// `MODEL_CATALOG` rather than synthesized. +const CURATED_LARGE: &str = "unsloth/Qwen3.8-27B-GGUF:Q4_K_M"; +const CURATED_LARGE_ALIAS: &str = "Qwen3.8-27B-Q4_K_M"; +const CURATED_MEDIUM: &str = "unsloth/Qwen3.5-9B-GGUF:Q4_K_M"; +const CURATED_MEDIUM_ALIAS: &str = "Qwen3.5-9B-Vision-Q4_K_M"; const CURATED_SMALL: &str = "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"; const CURATED_SMALL_ALIAS: &str = "Gemma-4-E4B-it-Q4_K_M"; -/// Rated-capacity boundary between the two curated tiers, in GB (marketing -/// capacity — a "64GB" Mac rates as 64 even though usable AI memory is less). +/// Superseded large alias retained only to preserve the historical string +/// canonicalization contract. Availability in a particular Mesh runtime is +/// determined by that runtime's compiled catalog. +const LEGACY_LARGE: &str = "unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"; +const LEGACY_LARGE_ALIAS: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M"; +/// Rated-capacity boundary for the balanced Qwen3.5 9B tier. +const CURATED_MEDIUM_MIN_RATED_GB: u64 = 32; +/// Qwen3.8 27B is recommended for 64 GB-and-larger rated capacity classes, +/// leaving substantial headroom beyond its observed working footprint for KV +/// cache and runtime use. const CURATED_LARGE_MIN_RATED_GB: u64 = 64; /// The Buzz-curated recommendation for a machine's rated memory capacity. fn buzz_recommended_model(rated_gb: Option) -> &'static str { match rated_gb { Some(gb) if gb >= CURATED_LARGE_MIN_RATED_GB => CURATED_LARGE, - _ => CURATED_SMALL, + Some(gb) if gb >= CURATED_MEDIUM_MIN_RATED_GB => CURATED_MEDIUM, + Some(_) | None => CURATED_SMALL, } } -/// Convert Buzz's pre-0.74 curated package aliases into the canonical model +/// Convert Buzz's historical curated package aliases into the canonical model /// ids advertised and accepted by Mesh's OpenAI ingress. pub(crate) fn canonical_curated_model_id(model_id: &str) -> &str { match model_id.trim() { CURATED_SMALL_ALIAS => CURATED_SMALL, + CURATED_MEDIUM_ALIAS => CURATED_MEDIUM, CURATED_LARGE_ALIAS => CURATED_LARGE, + LEGACY_LARGE_ALIAS => LEGACY_LARGE, other => other, } } @@ -172,31 +184,14 @@ fn build_catalog( }) .collect(); - // The compiled MODEL_CATALOG does not know the Buzz large pick; it - // resolves through mesh-llm's remote catalog at download time. Synthesize - // its entry so the picker can offer it. - if !entries.iter().any(|e| e.name == CURATED_LARGE) { - let size_gb = parse_size_gb(CURATED_LARGE_SIZE); - entries.push(MeshCatalogEntry { - fit: fit_code(size_gb, vram_gb), - installed: is_installed(CURATED_LARGE_FILE, CURATED_LARGE) - || is_installed(CURATED_LARGE_FILE, CURATED_LARGE_ALIAS), - recommended: false, - curated: false, - name: CURATED_LARGE.to_string(), - size: CURATED_LARGE_SIZE.to_string(), - size_gb, - description: CURATED_LARGE_DESCRIPTION.to_string(), - }); - } - let recommended = Some(buzz_recommended_model(rated_capacity_gb(vram_bytes)).to_string()); for entry in &mut entries { entry.recommended = recommended.as_deref() == Some(entry.name.as_str()); - // Both curated tiers are always offered: the recommended one for this - // machine plus the other pick (e.g. the small one as an explicit - // lighter choice on big machines). - entry.curated = entry.name == CURATED_LARGE || entry.name == CURATED_SMALL; + // All curated tiers are always offered: the recommendation plus + // lighter and heavier alternatives appropriate to other machine tiers. + entry.curated = entry.name == CURATED_LARGE + || entry.name == CURATED_MEDIUM + || entry.name == CURATED_SMALL; } entries.sort_by(|a, b| { @@ -269,20 +264,31 @@ mod tests { } #[test] - fn recommendation_follows_buzz_curated_tiers() { + fn recommendation_follows_buzz_curated_ladder() { assert_eq!(CURATED_SMALL, "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"); - assert_eq!(CURATED_LARGE, "unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"); - // 64GB+ rated machines get the large curated pick. + assert_eq!(CURATED_MEDIUM, "unsloth/Qwen3.5-9B-GGUF:Q4_K_M"); + assert_eq!(CURATED_LARGE, "unsloth/Qwen3.8-27B-GGUF:Q4_K_M"); + + // Qwen3.8 is recommended for 64 GB-and-larger rated capacity classes. let large = build_catalog(None, 64_000_000_000, 64.0, &[]); assert_eq!(large.recommended.as_deref(), Some(CURATED_LARGE)); + let large_80 = build_catalog(None, 80_000_000_000, 80.0, &[]); + assert_eq!(large_80.recommended.as_deref(), Some(CURATED_LARGE)); let big = build_catalog(None, 128_000_000_000, 128.0, &[]); assert_eq!(big.recommended.as_deref(), Some(CURATED_LARGE)); - // Below the boundary: the small curated pick — never a reasoning - // model, never sub-4B guesswork. - let small = build_catalog(None, 32_000_000_000, 32.0, &[]); + + // The balanced Qwen3.5 tier covers every rated class from 32 GB up + // to (but not including) 64 GB. + let medium = build_catalog(None, 32_000_000_000, 32.0, &[]); + assert_eq!(medium.recommended.as_deref(), Some(CURATED_MEDIUM)); + let medium_max = build_catalog(None, 48_000_000_000, 48.0, &[]); + assert_eq!(medium_max.recommended.as_deref(), Some(CURATED_MEDIUM)); + + // Smaller and unknown machines use the light Gemma tier. + let small = build_catalog(None, 24_000_000_000, 24.0, &[]); assert_eq!(small.recommended.as_deref(), Some(CURATED_SMALL)); - let tiny = build_catalog(None, 16_000_000_000, 16.0, &[]); - assert_eq!(tiny.recommended.as_deref(), Some(CURATED_SMALL)); + let unknown = build_catalog(None, 0, 0.0, &[]); + assert_eq!(unknown.recommended.as_deref(), Some(CURATED_SMALL)); } #[test] @@ -291,10 +297,15 @@ mod tests { canonical_curated_model_id(CURATED_SMALL_ALIAS), CURATED_SMALL ); + assert_eq!( + canonical_curated_model_id(CURATED_MEDIUM_ALIAS), + CURATED_MEDIUM + ); assert_eq!( canonical_curated_model_id(CURATED_LARGE_ALIAS), CURATED_LARGE ); + assert_eq!(canonical_curated_model_id(LEGACY_LARGE_ALIAS), LEGACY_LARGE); assert_eq!( canonical_curated_model_id("other/model:Q4"), "other/model:Q4" @@ -304,14 +315,17 @@ mod tests { #[test] fn curated_picks_lead_the_catalog() { let catalog = build_catalog(None, 96_000_000_000, 96.0, &[]); - // Recommended curated entry first, the other curated pick second, - // advanced entries after. + // Recommended curated entry first, then the other two curated tiers, + // with advanced entries after them. assert_eq!(catalog.entries[0].name, CURATED_LARGE); assert!(catalog.entries[0].recommended && catalog.entries[0].curated); - assert_eq!(catalog.entries[1].name, CURATED_SMALL); + assert_eq!(catalog.entries[1].name, CURATED_MEDIUM); assert!(catalog.entries[1].curated && !catalog.entries[1].recommended); - assert!(catalog.entries[2..].iter().all(|e| !e.curated)); - // The synthesized large pick carries a real size for fit ranking. + assert_eq!(catalog.entries[2].name, CURATED_SMALL); + assert!(catalog.entries[2].curated && !catalog.entries[2].recommended); + assert!(catalog.entries[3..].iter().all(|e| !e.curated)); + // The large pick comes from the compiled catalog with a real size, so + // fit ranking is meaningful rather than a placeholder. assert!(catalog.entries[0].size_gb > 10.0); } diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index e206c53886a..daab3c4b1c9 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -34,9 +34,9 @@ mod usage; pub use usage::{serving_usage_from_payload, MeshServingUsage}; mod transport_policy; +use transport_policy::{iroh_relay_mode, sdk_iroh_relay_config, validate_advertised_endpoint}; #[cfg(test)] -use transport_policy::iroh_relay_mode_from; -use transport_policy::{iroh_relay_mode, validate_advertised_endpoint, IrohRelayMode}; +use transport_policy::{iroh_relay_mode_from, IrohRelayMode}; use mesh_llm_sdk::{client, serve, EmbeddedNodeHandle, MeshDiscoveryMode, TrustPolicy}; use serde::{Deserialize, Serialize}; @@ -376,13 +376,10 @@ impl DesktopMeshRuntime { if let Some(mesh_name) = request.mesh_name.as_deref() { builder = builder.mesh_name(mesh_name); } - builder = match iroh_relay_mode()? { - IrohRelayMode::Disabled => builder.disable_iroh_relays(true), - IrohRelayMode::Default => builder.disable_iroh_relays(false), - IrohRelayMode::Custom(urls) => builder - .disable_iroh_relays(false) - .iroh_relays(urls.into_iter().map(|url| url.to_string())), - }; + let (disable_iroh_relays, iroh_relays) = sdk_iroh_relay_config(iroh_relay_mode()?); + builder = builder + .disable_iroh_relays(disable_iroh_relays) + .iroh_relays(iroh_relays); if let Some(max_vram_gb) = request.max_vram_gb { builder = builder.max_vram_gb(max_vram_gb as f64); } @@ -416,13 +413,10 @@ impl DesktopMeshRuntime { if let Some(mesh_name) = request.mesh_name.as_deref() { builder = builder.mesh_name(mesh_name); } - builder = match iroh_relay_mode()? { - IrohRelayMode::Disabled => builder.disable_iroh_relays(true), - IrohRelayMode::Default => builder.disable_iroh_relays(false), - IrohRelayMode::Custom(urls) => builder - .disable_iroh_relays(false) - .iroh_relays(urls.into_iter().map(|url| url.to_string())), - }; + let (disable_iroh_relays, iroh_relays) = sdk_iroh_relay_config(iroh_relay_mode()?); + builder = builder + .disable_iroh_relays(disable_iroh_relays) + .iroh_relays(iroh_relays); if let Some(join_token) = request.join_token.as_deref() { builder = builder.join_token(join_token); } diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index 6398f472505..a8b5584fd55 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -443,6 +443,7 @@ mod tests { id: pubkey.to_string(), display_name: pubkey.to_string(), avatar_url: None, + description: None, system_prompt: String::new(), runtime: None, model: None, diff --git a/desktop/src-tauri/src/mesh_llm/transport_policy.rs b/desktop/src-tauri/src/mesh_llm/transport_policy.rs index 122bffd60e4..24e5156295a 100644 --- a/desktop/src-tauri/src/mesh_llm/transport_policy.rs +++ b/desktop/src-tauri/src/mesh_llm/transport_policy.rs @@ -43,6 +43,22 @@ pub(super) fn iroh_relay_mode_from(raw: Option<&str>) -> anyhow::Result (bool, Vec) { + match mode { + IrohRelayMode::Disabled => (true, Vec::new()), + IrohRelayMode::Default => ( + false, + MESH_LLM_DEFAULT_RELAYS + .iter() + .map(|url| (*url).to_string()) + .collect(), + ), + IrohRelayMode::Custom(urls) => { + (false, urls.into_iter().map(|url| url.to_string()).collect()) + } + } +} + fn parse_configured_relay_url(raw: &str) -> anyhow::Result { let parsed = url::Url::parse(raw) .map_err(|error| anyhow::anyhow!("invalid relay URL {raw:?}: {error}"))?; @@ -205,9 +221,11 @@ fn validate_transport(transport: &TransportAddr, mode: &IrohRelayMode) -> anyhow /// mesh-llm serving node (they are not in iroh's own prod relay map). /// /// Kept in sync with `effective_relay_urls(RelayPolicy::DefaultPublic, &[])`. -const MESH_LLM_DEFAULT_RELAYS: &[&str] = &[ +pub(super) const MESH_LLM_DEFAULT_RELAYS: &[&str] = &[ "https://usw1-2.relay.michaelneale.mesh-llm.iroh.link./", "https://aps1-1.relay.michaelneale.mesh-llm.iroh.link./", + "https://euc1-1.relay.michaelneale.mesh-llm.iroh.link./", + "https://use1-1.relay.michaelneale.mesh-llm.iroh.link./", ]; /// Whether `relay` is one of mesh-llm's baked-in default public relays. @@ -327,6 +345,27 @@ mod tests { } } + #[test] + fn sdk_default_relays_are_exactly_the_trusted_default_relays() { + let (disabled, configured) = sdk_iroh_relay_config(IrohRelayMode::Default); + assert!(!disabled); + let configured = configured + .into_iter() + .map(|url| url.parse::().expect("SDK relay URL must parse")) + .collect::>(); + let trusted = MESH_LLM_DEFAULT_RELAYS + .iter() + .map(|url| { + url.parse::() + .expect("trusted relay URL must parse") + }) + .collect::>(); + assert_eq!(configured, trusted); + assert!(configured + .iter() + .all(|relay| relay_allowed(relay, &IrohRelayMode::Default))); + } + #[test] fn endpoint_with_one_good_and_one_junk_candidate_is_sanitized() { // A mesh-llm endpoint can advertise a usable relay alongside an diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts index 355c8e5d4f2..04ce9256a30 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -10,7 +10,7 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import type { Channel } from "@/shared/api/types"; @@ -72,7 +72,7 @@ export function useTrayMenu({ activityId: `${channelTurn.channelId}:${normalizePubkey(pubkey)}`, agentName: agentNames.get(normalizePubkey(pubkey)) ?? - `Agent ${truncatePubkey(pubkey)}`, + `Agent ${truncateNpub(pubkey)}`, channelId: channelTurn.channelId, channelName: channelNames.get(channelTurn.channelId) ?? "Unknown channel", diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index dfb9c0ed494..7f1f73b1752 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -306,7 +306,7 @@ with a TypeScript lookup table or an id comparison in a component. refresh only local persona/team/managed-agent caches; they must never invalidate the remote relay directory. -17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label. +17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs retain neutral effort capabilities. A boundary-matched GPT-5-or-newer family in the service-name component selects OpenAI Responses so tools can coexist with reasoning; other FQNs use MLflow Chat Completions. Catalog/schema components never influence routing. Keep this route-only rule identical in the Rust and TypeScript capability interpreters. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label. ## Channel-only runtime controls diff --git a/desktop/src/features/agents/lib/respondToAllowlist.test.mjs b/desktop/src/features/agents/lib/respondToAllowlist.test.mjs index bbe07f72040..0d494652e74 100644 --- a/desktop/src/features/agents/lib/respondToAllowlist.test.mjs +++ b/desktop/src/features/agents/lib/respondToAllowlist.test.mjs @@ -6,6 +6,10 @@ import { mergeAllowlist, parsePubkeyInput } from "./respondToAllowlist.ts"; const HEX_A = "a".repeat(64); const HEX_B = "b".repeat(64); const HEX_A_UPPER = "A".repeat(64); +// Handoff vector, round-trip verified with nostr-tools nip19. +const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; +const HEX_NPUB = + "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60"; test("parsePubkeyInput splits on commas, whitespace, and newlines", () => { const input = `${HEX_A}, ${HEX_B}\n${HEX_A_UPPER}`; @@ -25,11 +29,15 @@ test("parsePubkeyInput surfaces invalid entries separately", () => { assert.deepEqual(result.invalid, ["notgood", "z".repeat(64)]); }); -test("parsePubkeyInput rejects npub-style strings (hex only)", () => { - const npub = `npub1${"a".repeat(59)}`; - const result = parsePubkeyInput(npub); - assert.deepEqual(result.valid, []); - assert.deepEqual(result.invalid, [npub]); +test("parsePubkeyInput accepts npub entries, normalizes to hex, and dedupes across spellings", () => { + // One invalid npub-shaped token proves classification at this seam; the + // full codec negative matrix lives at the shared parser (nostrUtils). + const corrupt = `${HEX_NPUB.slice(0, -2)}qq`; + const result = parsePubkeyInput( + `${HEX_NPUB} ${HEX} ${HEX.toUpperCase()} ${corrupt}`, + ); + assert.deepEqual(result.valid, [HEX]); + assert.deepEqual(result.invalid, [corrupt]); }); test("parsePubkeyInput rejects wrong-length entries", () => { @@ -60,3 +68,8 @@ test("mergeAllowlist skips invalid additions silently", () => { const merged = mergeAllowlist([HEX_A], ["not-hex", HEX_B]); assert.deepEqual(merged, [HEX_A, HEX_B]); }); + +test("mergeAllowlist normalizes npub additions to canonical hex and dedupes", () => { + assert.deepEqual(mergeAllowlist([HEX_A], [HEX_NPUB]), [HEX_A, HEX]); + assert.deepEqual(mergeAllowlist([HEX], [HEX_NPUB]), [HEX]); +}); diff --git a/desktop/src/features/agents/lib/respondToAllowlist.ts b/desktop/src/features/agents/lib/respondToAllowlist.ts index c376aa1d1af..5325cacab47 100644 --- a/desktop/src/features/agents/lib/respondToAllowlist.ts +++ b/desktop/src/features/agents/lib/respondToAllowlist.ts @@ -5,9 +5,15 @@ * `desktop/src-tauri/src/managed_agents/types.rs::validate_respond_to_allowlist`). * These helpers exist to give the UI immediate, inline feedback before the * round-trip, and to normalize input so the Rust validator sees clean data. + * + * Entry pieces may be 64-char hex pubkeys or bech32 `npub1…` strings; both are + * normalized to the canonical lowercase hex via the shared + * `parsePubkeyInput`, so npub and hex spellings of the same key dedupe to one + * entry (users copy npubs from profile/verify surfaces elsewhere in the app). */ -const HEX_64 = /^[0-9a-f]{64}$/i; +import { parsePubkeyInput as parseCanonicalPubkey } from "@/shared/lib/nostrUtils"; +import { normalizePubkey } from "@/shared/lib/pubkey"; export type ParsedAllowlist = { /** Successfully parsed entries — lowercase hex, deduplicated, in order. */ @@ -22,9 +28,9 @@ export type ParsedAllowlist = { * pattern used by `ChannelMemberInviteCard` so users have one mental model. * * - Splits on `/[\s,]+/`. - * - Trims and lowercases each entry. - * - Validates each entry is exactly 64 hex chars. - * - Deduplicates while preserving insertion order. + * - Accepts 64-char hex (any case) or `npub1…` bech32 per piece, normalizing + * to the canonical lowercase hex pubkey. + * - Deduplicates the canonical form while preserving insertion order. */ export function parsePubkeyInput(raw: string): ParsedAllowlist { const seen = new Set(); @@ -33,14 +39,14 @@ export function parsePubkeyInput(raw: string): ParsedAllowlist { for (const piece of raw.split(/[\s,]+/)) { const trimmed = piece.trim(); if (trimmed.length === 0) continue; - if (!HEX_64.test(trimmed)) { + const canonical = parseCanonicalPubkey(trimmed); + if (canonical === null) { invalid.push(trimmed); continue; } - const lower = trimmed.toLowerCase(); - if (!seen.has(lower)) { - seen.add(lower); - valid.push(lower); + if (!seen.has(canonical)) { + seen.add(canonical); + valid.push(canonical); } } return { valid, invalid }; @@ -48,16 +54,20 @@ export function parsePubkeyInput(raw: string): ParsedAllowlist { /** * Merge an existing allowlist with newly-added pubkeys, normalizing and - * deduplicating without reordering existing entries. + * deduplicating without reordering existing entries. Both hex and npub + * spellings normalize to the canonical hex, so the same key cannot enter + * twice regardless of the form it was added in. */ export function mergeAllowlist(existing: string[], add: string[]): string[] { - const seen = new Set(existing.map((p) => p.toLowerCase())); - const out = [...existing.map((p) => p.toLowerCase())]; + const normalize = (pubkey: string): string => + parseCanonicalPubkey(pubkey) ?? normalizePubkey(pubkey); + const out = existing.map(normalize); + const seen = new Set(out); for (const candidate of add) { - const lower = candidate.toLowerCase(); - if (!HEX_64.test(lower) || seen.has(lower)) continue; - seen.add(lower); - out.push(lower); + const canonical = parseCanonicalPubkey(candidate); + if (canonical === null || seen.has(canonical)) continue; + seen.add(canonical); + out.push(canonical); } return out; } diff --git a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx index 05441e86180..366a19d7ca3 100644 --- a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx @@ -9,7 +9,11 @@ import { useChannelsQuery, } from "@/features/channels/hooks"; import type { Channel, ChannelRole, ManagedAgent } from "@/shared/api/types"; -import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + canonicalNpub, + normalizePubkey, + UNAVAILABLE_KEY_LABEL, +} from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -87,6 +91,10 @@ export function AddAgentToChannelDialog({ ); }, [agent?.pubkey, membersQuery.data]); + // The agent's public key displays as its full canonical npub; an + // unencodable key renders the neutral label and is not copyable. + const agentNpub = agent?.pubkey ? canonicalNpub(agent.pubkey) : null; + const selectedChannel = channels.find((channel) => channel.id === channelId) ?? null; @@ -186,10 +194,12 @@ export function AddAgentToChannelDialog({

- {agent?.pubkey ?? "No agent selected"} + {agent + ? (agentNpub ?? UNAVAILABLE_KEY_LABEL) + : "No agent selected"} - {agent ? ( - + {agent && agentNpub ? ( + ) : null}
diff --git a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx index 7db7c32bfa5..89588b57567 100644 --- a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx +++ b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx @@ -15,7 +15,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { UserSearchResult } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -25,7 +25,7 @@ export function formatShareRecipientName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx index 9692d2fd1f7..44c6fd146ed 100644 --- a/desktop/src/features/agents/ui/RespondToField.tsx +++ b/desktop/src/features/agents/ui/RespondToField.tsx @@ -4,7 +4,8 @@ import { mergeAllowlist, parsePubkeyInput, } from "@/features/agents/lib/respondToAllowlist"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { parsePubkeyInput as parseCanonicalPubkey } from "@/shared/lib/nostrUtils"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserSearchQuery } from "@/features/profile/hooks"; @@ -59,7 +60,7 @@ function formatSearchUserName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -69,7 +70,7 @@ function formatSearchUserSecondary(user: UserSearchResult) { if (displayName && nip05Handle) { return nip05Handle; } - return truncatePubkey(user.pubkey); + return truncateNpub(user.pubkey); } const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [ @@ -278,8 +279,6 @@ export function CreateAgentRespondToField({ ); } -const HEX_64_RE = /^[0-9a-f]{64}$/i; - function AllowlistPicker({ allowlist, deferredQuery, @@ -325,10 +324,12 @@ function AllowlistPicker({ }) { const isPersona = variant === "persona"; - // Detect if the query is a valid hex pubkey that's not already in the list. - const queryIsHexPubkey = - HEX_64_RE.test(deferredQuery) && - !allowlist.some((p) => p.toLowerCase() === deferredQuery.toLowerCase()); + // Detect if the query is a pubkey (npub or hex) not already in the list; + // direct entry offers the canonical hex for storage. + const queryPubkey = parseCanonicalPubkey(deferredQuery); + const queryIsDirectPubkey = + queryPubkey !== null && + !allowlist.some((p) => p.toLowerCase() === queryPubkey); return (
))}
- ) : queryIsHexPubkey ? ( + ) : queryIsDirectPubkey ? (