diff --git a/harness/README.md b/harness/README.md index 8c96f716..9d11115f 100644 --- a/harness/README.md +++ b/harness/README.md @@ -10,7 +10,7 @@ The current product surface is intentionally small: - `status` reports Agent Integration, Local Mnemon, and sync status. - `sync` connects Local Mnemon to a Remote Workspace and pushes/pulls governed commits with attribution preserved. The first-party backend is `mnemon-hub`; - the experimental GitHub backend uses repo-mediated publication branches. + future backends must preserve the same EventEnvelope sync contract. - `loop validate` remains hidden and is used by `make harness-validate`. Host directories such as `.codex` and `.claude` are projection surfaces. Runtime @@ -49,40 +49,3 @@ Remove projected assets for a principal: ``` More command examples are in `docs/harness/USAGE.md`. - -## Experimental GitHub Remote Workspace - -GitHub can be used as a bootstrap Remote Workspace backend for a decentralized -publication mesh. Each Local Mnemon publishes accepted synced events to its own -configured branch and subscribes to explicitly configured peer branches. - -This is not P2P networking or GitHub Issues/PR-based teamwork. GitHub is only the -publication substrate; every imported event still enters through the receiving -Local Mnemon's Event Intake. - -Example shape: - -```sh -./mnemon-harness sync connect self \ - --backend github \ - --direction publish \ - --github-repo mnemon-dev/mnemon-teamwork-example \ - --github-branch mnemon/agent-a \ - --token-file ~/.config/mnemon/github.token - -./mnemon-harness sync connect agent-b \ - --backend github \ - --direction subscribe \ - --github-repo mnemon-dev/mnemon-teamwork-example \ - --github-branch mnemon/agent-b \ - --token-file ~/.config/mnemon/github.token -``` - -Live validation is opt-in: - -```sh -MNEMON_GITHUB_LIVE=1 \ -MNEMON_GITHUB_REPO=mnemon-dev/mnemon-teamwork-example \ -MNEMON_GITHUB_TOKEN_FILE=~/.config/mnemon/github.token \ -go test ./harness/internal/app -run TestGitHubLivePublishPullImport -count=1 -v -``` diff --git a/harness/cloudflare/mnemonhub/package.json b/harness/cloudflare/mnemonhub/package.json new file mode 100644 index 00000000..f5a2d140 --- /dev/null +++ b/harness/cloudflare/mnemonhub/package.json @@ -0,0 +1,11 @@ +{ + "name": "@mnemon/harness-cloudflare-mnemonhub", + "private": true, + "type": "module", + "scripts": { + "test": "node --test test/*.test.mjs" + }, + "devDependencies": { + "wrangler": "^4.0.0" + } +} diff --git a/harness/cloudflare/mnemonhub/src/auth.mjs b/harness/cloudflare/mnemonhub/src/auth.mjs new file mode 100644 index 00000000..8793f934 --- /dev/null +++ b/harness/cloudflare/mnemonhub/src/auth.mjs @@ -0,0 +1,50 @@ +export class StaticAuthenticator { + constructor(tokens = {}) { + this.tokens = tokens; + } + + authenticate(request) { + const header = request.headers.get("authorization") ?? ""; + const token = header.trim().replace(/^Bearer\s+/i, "").trim(); + if (!token || !Object.prototype.hasOwnProperty.call(this.tokens, token)) { + throw new Error("unrecognized bearer token"); + } + const principal = String(this.tokens[token] ?? "").trim(); + if (!principal) { + throw new Error("unrecognized bearer token"); + } + return principal; + } +} + +export function authFromEnv(env = {}) { + return new StaticAuthenticator(parseJSONRecord(env.MNEMON_HUB_TOKENS_JSON)); +} + +export function grantsFromEnv(env = {}) { + const raw = parseJSONRecord(env.MNEMON_HUB_GRANTS_JSON); + const out = {}; + for (const [principal, value] of Object.entries(raw)) { + if (Array.isArray(value)) { + out[principal] = { principal, scopes: value }; + } else if (value && typeof value === "object" && Array.isArray(value.scopes)) { + out[principal] = { principal: value.principal || principal, scopes: value.scopes }; + } + } + return out; +} + +function parseJSONRecord(value) { + if (!value) { + return {}; + } + if (typeof value === "object") { + return value; + } + try { + const parsed = JSON.parse(String(value)); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} diff --git a/harness/cloudflare/mnemonhub/src/contract.mjs b/harness/cloudflare/mnemonhub/src/contract.mjs new file mode 100644 index 00000000..9e37f3b4 --- /dev/null +++ b/harness/cloudflare/mnemonhub/src/contract.mjs @@ -0,0 +1,251 @@ +import { sha256Hex } from "./json.mjs"; + +export const SyncVerbPush = "sync.push"; +export const SyncVerbPull = "sync.pull"; +export const SyncVerbStatus = "sync.status"; + +const phaseMeta = { + synced: { + origin_mnemond: "string", + cursor: "string", + digest: "string", + synced_at: "string" + } +}; + +const payloadSections = new Set(["rule", "narrative", "refs"]); +const phaseMetaKeys = new Set(["external_id", "host", "lifecycle", "decision_id", "ingest_seq", "accepted_at", "accepted_by", "origin_mnemond", "cursor", "digest", "synced_at", "derived_at", "expires_at", "presentation_hint", "suggested_event_types"]); + +export function subject(kind, id) { + kind = String(kind ?? "").trim(); + id = String(id ?? "").trim(); + if (!kind || !id) { + return ""; + } + return `${kind}/${id}`; +} + +export function resourceRefFromSubject(value) { + const [kind, id, extra] = String(value ?? "").split("/"); + if (!kind || !id || extra !== undefined) { + throw new BadRequestError(`event subject ${JSON.stringify(value)} is not kind/id`); + } + return { kind, id }; +} + +export function localDecisionID(eventID) { + return String(eventID ?? "").trim().split(":", 1)[0]; +} + +export async function fieldsDigest(fields) { + return sha256Hex(fields ?? {}); +} + +export async function syncedEventEnvelopeFromMaterial(material) { + const ref = material.resource_ref; + const eventSubject = subject(ref?.kind, ref?.id); + const decidedAt = String(material.decided_at ?? ""); + const event = { + schema_version: 2, + id: `${String(material.local_decision_id || "decision").trim()}:${eventSubject}`, + type: `${ref.kind}.accepted`, + subject: eventSubject, + actor: String(material.actor ?? ""), + audience: "", + payload: { + rule: { + resource_version: Number(material.resource_version ?? 0), + fields: cloneFields(material.fields) + } + }, + caused_by: [], + correlation_id: String(material.correlation_id ?? ""), + ttl: "", + created_at: decidedAt + }; + const env = { + schema_version: 2, + phase: "synced", + event, + meta: { + origin_mnemond: String(material.origin_replica_id ?? "").trim(), + cursor: String(material.local_ingest_seq ?? ""), + digest: String(material.fields_digest ?? "").trim(), + synced_at: decidedAt + } + }; + validateEnvelope(env); + return env; +} + +export function syncedEventMaterialFromEnvelope(env) { + validateEnvelope(env); + if (env.phase !== "synced") { + throw new Error(`sync event must have phase "synced", got ${JSON.stringify(env.phase)}`); + } + const ref = resourceRefFromSubject(env.event.subject); + const decisionID = localDecisionID(env.event.id); + if (!decisionID) { + throw new Error(`synced event ${JSON.stringify(env.event.id)} does not carry a decision identity`); + } + const rule = env.event.payload?.rule; + const resourceVersion = Number(rule?.resource_version); + if (!Number.isFinite(resourceVersion)) { + throw new Error(`synced event ${JSON.stringify(env.event.id)} has invalid resource_version`); + } + if (!isPlainObject(rule?.fields)) { + throw new Error(`synced event ${JSON.stringify(env.event.id)} payload.rule.fields must be an object`); + } + return { + origin_replica_id: String(env.meta.origin_mnemond ?? "").trim(), + local_decision_id: decisionID, + local_ingest_seq: parseInt(String(env.meta.cursor ?? "0"), 10) || 0, + actor: String(env.event.actor ?? ""), + correlation_id: String(env.event.correlation_id ?? ""), + resource_ref: ref, + resource_version: resourceVersion, + fields_digest: String(env.meta.digest ?? "").trim(), + fields: cloneFields(rule.fields), + decided_at: String(env.meta.synced_at ?? "").trim(), + status: "pending" + }; +} + +export function eventExchangeResultFromEnvelope(env, status, diagnostic = "") { + return { + origin_mnemond: String(env?.meta?.origin_mnemond ?? "").trim(), + event_id: String(env?.event?.id ?? ""), + subject: String(env?.event?.subject ?? ""), + status, + diagnostic + }; +} + +export async function validateSyncedEventMaterial(material) { + if (!String(material.origin_replica_id ?? "").trim()) { + return "origin_replica_id is required"; + } + if (!String(material.local_decision_id ?? "").trim()) { + return "local_decision_id is required"; + } + if (!String(material.actor ?? "").trim()) { + return "actor is required"; + } + if (!String(material.resource_ref?.kind ?? "").trim() || !String(material.resource_ref?.id ?? "").trim()) { + return "resource_ref is required"; + } + if (!isPlainObject(material.fields)) { + return "fields are required"; + } + if (!String(material.fields_digest ?? "").trim()) { + return "fields_digest is required"; + } + const digest = await fieldsDigest(material.fields); + if (material.fields_digest !== digest) { + return "fields_digest does not match fields"; + } + return ""; +} + +export function clampRefs(principal, grantScopes, requestedScopes = []) { + const grant = Array.isArray(grantScopes) ? grantScopes : []; + const requested = Array.isArray(requestedScopes) && requestedScopes.length > 0 ? requestedScopes : grant; + const allowed = new Set(grant.map(refKey)); + for (const ref of requested) { + if (!allowed.has(refKey(ref))) { + throw new Error(`principal ${JSON.stringify(principal)} is not granted access to ${refKey(ref)}`); + } + } + return requested.map((ref) => ({ kind: String(ref.kind), id: String(ref.id) })); +} + +export function refKey(ref) { + return `${String(ref?.kind ?? "")}/${String(ref?.id ?? "")}`; +} + +export class BadRequestError extends Error { + constructor(message) { + super(message); + this.name = "BadRequestError"; + } +} + +export function validateEnvelope(env) { + if (!isPlainObject(env)) { + throw new Error("event envelope must be an object"); + } + if (!(Number(env.schema_version) > 0)) { + throw new Error("event envelope schema_version must be positive"); + } + if (env.phase !== "synced") { + throw new Error(`unknown event envelope phase ${JSON.stringify(env.phase)}`); + } + if (!isPlainObject(env.event)) { + throw new Error("event is required"); + } + validateEvent(env.event); + validateMeta(env.phase, env.meta); +} + +function validateEvent(event) { + if (event.schema_version !== 2) { + throw new Error("event schema_version must be 2"); + } + for (const key of ["id", "type", "subject", "actor"]) { + if (!String(event[key] ?? "").trim()) { + throw new Error(`event ${key} is required`); + } + } + validatePayload(event.payload); +} + +function validatePayload(payload) { + if (!isPlainObject(payload)) { + throw new Error("event payload must be an object"); + } + for (const [key, value] of Object.entries(payload)) { + if (phaseMetaKeys.has(key)) { + throw new Error(`event payload must not carry phase meta key ${JSON.stringify(key)}`); + } + if (!payloadSections.has(key)) { + throw new Error(`event payload must not carry business key ${JSON.stringify(key)} outside rule/narrative/refs`); + } + if (!isPlainObject(value)) { + throw new Error(`event payload section ${JSON.stringify(key)} must be an object`); + } + for (const sectionKey of Object.keys(value)) { + if (phaseMetaKeys.has(sectionKey)) { + throw new Error(`event payload section ${JSON.stringify(key)} must not carry phase meta key ${JSON.stringify(sectionKey)}`); + } + } + } +} + +function validateMeta(phase, meta) { + if (!isPlainObject(meta)) { + throw new Error(`${phase} event envelope meta is required`); + } + const schema = phaseMeta[phase]; + for (const key of Object.keys(meta)) { + if (!(key in schema)) { + throw new Error(`${phase} event envelope meta has unknown key ${JSON.stringify(key)}`); + } + } + for (const key of Object.keys(schema)) { + const value = meta[key]; + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${phase} event envelope meta key ${JSON.stringify(key)} must be a non-empty string`); + } + } +} + +export function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function cloneFields(fields) { + if (!isPlainObject(fields)) { + return {}; + } + return JSON.parse(JSON.stringify(fields)); +} diff --git a/harness/cloudflare/mnemonhub/src/http.mjs b/harness/cloudflare/mnemonhub/src/http.mjs new file mode 100644 index 00000000..41aa61b7 --- /dev/null +++ b/harness/cloudflare/mnemonhub/src/http.mjs @@ -0,0 +1,97 @@ +import { BadRequestError, SyncVerbPull, SyncVerbPush, SyncVerbStatus } from "./contract.mjs"; + +const maxBodyBytes = 8 << 20; + +export async function handleSyncRequest(request, hub, authenticator, audit = null) { + const url = new URL(request.url); + if (url.pathname === "/sync/push") { + const method = allowMethod(request, ["POST"], SyncVerbPush, audit); + if (method) return method; + const auth = authenticate(request, authenticator, SyncVerbPush, audit); + if (auth.response) return auth.response; + const decoded = await decodeJSON(request, SyncVerbPush, auth.principal, audit); + if (decoded.response) return decoded.response; + return respond(await hubCall(() => hub.push(auth.principal, decoded.value), auth.principal, SyncVerbPush, audit)); + } + if (url.pathname === "/sync/pull") { + const method = allowMethod(request, ["POST"], SyncVerbPull, audit); + if (method) return method; + const auth = authenticate(request, authenticator, SyncVerbPull, audit); + if (auth.response) return auth.response; + const decoded = await decodeJSON(request, SyncVerbPull, auth.principal, audit); + if (decoded.response) return decoded.response; + return respond(await hubCall(() => hub.pull(auth.principal, decoded.value), auth.principal, SyncVerbPull, audit)); + } + if (url.pathname === "/sync/status") { + const method = allowMethod(request, ["GET", "POST"], SyncVerbStatus, audit); + if (method) return method; + const auth = authenticate(request, authenticator, SyncVerbStatus, audit); + if (auth.response) return auth.response; + return respond(await hubCall(() => hub.status(auth.principal), auth.principal, SyncVerbStatus, audit)); + } + return new Response("not found\n", { status: 404 }); +} + +function allowMethod(request, allowed, verb, audit) { + if (allowed.includes(request.method)) { + return null; + } + auditLine(audit, "-", verb, "bad_request"); + return new Response("method not allowed\n", { + status: 405, + headers: { allow: allowed.join(", ") } + }); +} + +function authenticate(request, authenticator, verb, audit) { + try { + return { principal: authenticator.authenticate(request) }; + } catch (error) { + auditLine(audit, "-", verb, "unauthorized"); + return { response: new Response(`${error.message}\n`, { status: 401 }) }; + } +} + +async function decodeJSON(request, verb, principal, audit) { + try { + const text = await request.text(); + if (text.length > maxBodyBytes) { + throw new BadRequestError("request body too large"); + } + return { value: text.trim() ? JSON.parse(text) : {} }; + } catch (error) { + auditLine(audit, principal, verb, "bad_request"); + return { response: new Response(`${error.message}\n`, { status: 400 }) }; + } +} + +async function hubCall(call, principal, verb, audit) { + try { + const value = await call(); + auditLine(audit, principal, verb, "ok"); + return { value }; + } catch (error) { + if (error instanceof BadRequestError) { + auditLine(audit, principal, verb, "bad_request"); + return { response: new Response(`${error.message}\n`, { status: 400 }) }; + } + auditLine(audit, principal, verb, "denied"); + return { response: new Response(`${error.message}\n`, { status: 403 }) }; + } +} + +function respond(result) { + if (result.response) { + return result.response; + } + return new Response(JSON.stringify(result.value) + "\n", { + headers: { "content-type": "application/json" } + }); +} + +function auditLine(audit, principal, verb, result) { + if (!audit || typeof audit.push !== "function") { + return; + } + audit.push(`${new Date().toISOString()} principal=${principal} verb=${verb} result=${result}`); +} diff --git a/harness/cloudflare/mnemonhub/src/hub.mjs b/harness/cloudflare/mnemonhub/src/hub.mjs new file mode 100644 index 00000000..4b0cb9bf --- /dev/null +++ b/harness/cloudflare/mnemonhub/src/hub.mjs @@ -0,0 +1,189 @@ +import { + BadRequestError, + SyncVerbPull, + SyncVerbPush, + SyncVerbStatus, + clampRefs, + eventExchangeResultFromEnvelope, + refKey, + syncedEventEnvelopeFromMaterial, + syncedEventMaterialFromEnvelope, + validateSyncedEventMaterial +} from "./contract.mjs"; + +export class MnemonHubCore { + constructor({ grants = {}, store, now = () => new Date().toISOString() } = {}) { + this.grants = grants; + this.store = store; + this.now = now; + } + + async push(principal, request) { + const grant = this.grant(principal, SyncVerbPush); + if (!grant) { + throw new Error(`principal ${JSON.stringify(principal)} has no replica grant for ${SyncVerbPush}`); + } + const replicaID = String(request?.replica_id ?? "").trim(); + if (!replicaID) { + throw new BadRequestError("sync push requires replica_id"); + } + const response = { accepted: [], rejected: [], conflicts: [] }; + for (const envelope of Array.isArray(request?.events) ? request.events : []) { + let material; + try { + material = syncedEventMaterialFromEnvelope(envelope); + } catch (error) { + response.rejected.push(eventExchangeResultFromEnvelope(envelope, "rejected", error.message)); + continue; + } + if (material.origin_replica_id !== replicaID) { + throw new BadRequestError(`sync push replica_id ${JSON.stringify(replicaID)} does not match event origin ${JSON.stringify(material.origin_replica_id)}`); + } + const diagnostic = await validateSyncedEventMaterial(material); + if (diagnostic) { + response.rejected.push(eventExchangeResultFromEnvelope(envelope, "rejected", diagnostic)); + continue; + } + try { + clampRefs(principal, grant.scopes, [material.resource_ref]); + } catch (error) { + response.rejected.push(eventExchangeResultFromEnvelope(envelope, "rejected", error.message)); + continue; + } + const record = await this.recordRemoteSyncedEvent(String(principal), material); + switch (record.status) { + case "accepted": { + const acceptedEnvelope = await syncedEventEnvelopeFromMaterial(record.material); + acceptedEnvelope.meta.cursor = String(record.remote_seq); + response.accepted.push(eventExchangeResultFromEnvelope(acceptedEnvelope, "accepted", "")); + response.next_cursor = String(record.remote_seq); + break; + } + case "conflict": + response.conflicts.push(eventExchangeResultFromEnvelope(envelope, "conflict", record.diagnostic)); + break; + default: + response.rejected.push(eventExchangeResultFromEnvelope(envelope, record.status, record.diagnostic ?? "")); + } + } + return response; + } + + async pull(principal, request) { + const grant = this.grant(principal, SyncVerbPull); + if (!grant) { + throw new Error(`principal ${JSON.stringify(principal)} has no replica grant for ${SyncVerbPull}`); + } + const replicaID = String(request?.replica_id ?? "").trim(); + if (!replicaID) { + throw new BadRequestError("sync pull requires replica_id"); + } + let cursor = 0; + if (String(request?.remote_cursor ?? "").trim()) { + cursor = Number.parseInt(String(request.remote_cursor), 10); + if (!Number.isFinite(cursor)) { + throw new BadRequestError(`parse remote_cursor: invalid syntax`); + } + } + const scopes = clampRefs(principal, grant.scopes, request?.scopes ?? []); + const scopeSet = new Set(scopes.map(refKey)); + const state = await this.store.read(); + const records = state.events + .filter((record) => record.remote_seq > cursor) + .filter((record) => record.status === "accepted") + .filter((record) => record.material.origin_replica_id !== replicaID) + .filter((record) => scopeSet.has(refKey(record.material.resource_ref))) + .sort((a, b) => a.remote_seq - b.remote_seq) + .slice(0, 100); + let next = cursor; + const events = []; + for (const record of records) { + if (record.remote_seq > next) { + next = record.remote_seq; + } + const env = await syncedEventEnvelopeFromMaterial(record.material); + env.meta.cursor = String(record.remote_seq); + events.push(env); + } + if (records.length > 0) { + state.served_total += records.length; + } + state.serve_cursors[String(principal)] = next; + await this.store.write(state); + return { next_cursor: String(next), events }; + } + + async status(principal) { + if (!this.grant(principal, SyncVerbStatus)) { + throw new Error(`principal ${JSON.stringify(principal)} has no replica grant for ${SyncVerbStatus}`); + } + const state = await this.store.read(); + const cursors = {}; + for (const [principalID, cursor] of Object.entries(state.serve_cursors)) { + cursors[principalID] = String(cursor); + } + const response = { + principal, + remote_workspace: "connected", + hub_events_received: state.events.filter((record) => record.status === "accepted").length, + hub_events_served: state.served_total + }; + if (Object.keys(cursors).length > 0) { + response.hub_replica_cursors = cursors; + } + return response; + } + + grant(principal, verb) { + if (![SyncVerbPush, SyncVerbPull, SyncVerbStatus].includes(verb)) { + return null; + } + const grant = this.grants[String(principal)]; + if (!grant) { + return null; + } + return { principal: grant.principal || String(principal), scopes: Array.isArray(grant.scopes) ? grant.scopes : [] }; + } + + async recordRemoteSyncedEvent(remotePeerID, material) { + const state = await this.store.read(); + const existing = state.events.find((record) => + record.remote_peer_id === remotePeerID && + record.material.origin_replica_id === material.origin_replica_id && + record.material.local_decision_id === material.local_decision_id + ); + if (existing) { + if (sameSyncedEventMaterial(existing.material, material)) { + return existing; + } + return { + remote_peer_id: remotePeerID, + material, + status: "conflict", + diagnostic: "sync idempotency key reused with different event" + }; + } + const accepted = JSON.parse(JSON.stringify(material)); + accepted.status = "accepted"; + const record = { + remote_seq: state.next_seq++, + remote_peer_id: remotePeerID, + material: accepted, + received_at: this.now(), + status: "accepted", + diagnostic: "" + }; + state.events.push(record); + await this.store.write(state); + return record; + } +} + +function sameSyncedEventMaterial(a, b) { + return Number(a.local_ingest_seq) === Number(b.local_ingest_seq) && + String(a.actor ?? "") === String(b.actor ?? "") && + String(a.correlation_id ?? "") === String(b.correlation_id ?? "") && + refKey(a.resource_ref) === refKey(b.resource_ref) && + Number(a.resource_version) === Number(b.resource_version) && + String(a.fields_digest ?? "") === String(b.fields_digest ?? ""); +} diff --git a/harness/cloudflare/mnemonhub/src/index.mjs b/harness/cloudflare/mnemonhub/src/index.mjs new file mode 100644 index 00000000..c8191755 --- /dev/null +++ b/harness/cloudflare/mnemonhub/src/index.mjs @@ -0,0 +1,35 @@ +import { StaticAuthenticator, authFromEnv, grantsFromEnv } from "./auth.mjs"; +import { MnemonHubCore } from "./hub.mjs"; +import { DurableObjectHubStore } from "./storage.mjs"; +import { handleSyncRequest } from "./http.mjs"; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + const workspace = url.searchParams.get("workspace") || env.MNEMON_HUB_WORKSPACE || "default"; + const id = env.MNEMON_HUB.idFromName(workspace); + return env.MNEMON_HUB.get(id).fetch(request); + } +}; + +export class MnemonHubDO { + constructor(ctx, env) { + this.ctx = ctx; + this.env = env; + this.audit = []; + } + + async fetch(request) { + const hub = new MnemonHubCore({ + grants: grantsFromEnv(this.env), + store: new DurableObjectHubStore(this.ctx.storage) + }); + return handleSyncRequest(request, hub, authFromEnv(this.env), this.audit); + } +} + +export function createMemoryHandler({ grants, tokens, store, audit }) { + const hub = new MnemonHubCore({ grants, store }); + const auth = new StaticAuthenticator(tokens); + return (request) => handleSyncRequest(request, hub, auth, audit); +} diff --git a/harness/cloudflare/mnemonhub/src/json.mjs b/harness/cloudflare/mnemonhub/src/json.mjs new file mode 100644 index 00000000..0a101ef3 --- /dev/null +++ b/harness/cloudflare/mnemonhub/src/json.mjs @@ -0,0 +1,25 @@ +const textEncoder = new TextEncoder(); + +export function stableStringify(value) { + return JSON.stringify(sortJSON(value)); +} + +function sortJSON(value) { + if (Array.isArray(value)) { + return value.map(sortJSON); + } + if (value && typeof value === "object") { + const out = {}; + for (const key of Object.keys(value).sort()) { + out[key] = sortJSON(value[key]); + } + return out; + } + return value; +} + +export async function sha256Hex(value) { + const data = typeof value === "string" ? value : stableStringify(value); + const digest = await globalThis.crypto.subtle.digest("SHA-256", textEncoder.encode(data)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/harness/cloudflare/mnemonhub/src/storage.mjs b/harness/cloudflare/mnemonhub/src/storage.mjs new file mode 100644 index 00000000..ed8b774c --- /dev/null +++ b/harness/cloudflare/mnemonhub/src/storage.mjs @@ -0,0 +1,44 @@ +const stateKey = "mnemonhub:v1"; + +export class MemoryHubStore { + constructor(initialState) { + this.state = normalizeState(initialState); + } + + async read() { + return normalizeState(this.state); + } + + async write(state) { + this.state = normalizeState(state); + } +} + +export class DurableObjectHubStore { + constructor(storage) { + this.storage = storage; + } + + async read() { + const state = await this.storage.get(stateKey); + return normalizeState(state); + } + + async write(state) { + await this.storage.put(stateKey, normalizeState(state)); + } +} + +export function normalizeState(state = {}) { + return { + next_seq: Number.isInteger(state.next_seq) && state.next_seq > 0 ? state.next_seq : 1, + events: Array.isArray(state.events) ? JSON.parse(JSON.stringify(state.events)) : [], + serve_cursors: isRecord(state.serve_cursors) ? { ...state.serve_cursors } : {}, + served_total: Number.isInteger(state.served_total) && state.served_total >= 0 ? state.served_total : 0, + audit_records: Array.isArray(state.audit_records) ? JSON.parse(JSON.stringify(state.audit_records)) : [] + }; +} + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/harness/cloudflare/mnemonhub/test/fixtures.test.mjs b/harness/cloudflare/mnemonhub/test/fixtures.test.mjs new file mode 100644 index 00000000..1d71d95f --- /dev/null +++ b/harness/cloudflare/mnemonhub/test/fixtures.test.mjs @@ -0,0 +1,193 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; + +import { fieldsDigest, syncedEventEnvelopeFromMaterial } from "../src/contract.mjs"; +import { MnemonHubCore } from "../src/hub.mjs"; +import { createMemoryHandler } from "../src/index.mjs"; +import { MemoryHubStore } from "../src/storage.mjs"; + +const fixtureRoot = resolve("../../testdata/mnemonhub/sync-abi"); + +for (const fixture of await loadFixtures()) { + test(`sync ABI: ${fixture.name}`, async () => { + switch (fixture.kind) { + case "push": + await runPushFixture(fixture); + break; + case "push_replay": + await runReplayFixture(fixture); + break; + case "push_conflict": + await runConflictFixture(fixture); + break; + case "pull": + await runPullFixture(fixture); + break; + case "status": + await runStatusFixture(fixture); + break; + case "http_auth": + await runHTTPAuthFixture(fixture); + break; + default: + throw new Error(`unknown fixture kind ${fixture.kind}`); + } + }); +} + +async function loadFixtures() { + const names = (await readdir(fixtureRoot)).filter((name) => name.endsWith(".json")).sort(); + const fixtures = []; + for (const name of names) { + fixtures.push(JSON.parse(await readFile(join(fixtureRoot, name), "utf8"))); + } + assert.ok(fixtures.length > 0, "expected sync ABI fixtures"); + return fixtures; +} + +function openFixtureHub(fixture) { + const grants = {}; + if (fixture.principal) { + grants[fixture.principal] = { principal: fixture.principal, scopes: fixture.grant_scopes ?? [] }; + } + if (fixture.seed_principal) { + grants[fixture.seed_principal] = { principal: fixture.seed_principal, scopes: fixture.grant_scopes ?? [] }; + } + const store = new MemoryHubStore(); + return { hub: new MnemonHubCore({ grants, store, now: () => "2026-06-12T00:00:00Z" }), store }; +} + +async function runPushFixture(fixture) { + const { hub } = openFixtureHub(fixture); + const response = await hub.push(fixture.principal, { + replica_id: fixture.replica_id, + batch_id: fixture.name, + events: await fixtureEvents(fixture.events ?? []) + }); + assertCounts(fixture, response); +} + +async function runReplayFixture(fixture) { + const { hub, store } = openFixtureHub(fixture); + const request = { + replica_id: fixture.replica_id, + batch_id: fixture.name, + events: await fixtureEvents(fixture.events ?? []) + }; + const first = await hub.push(fixture.principal, request); + const second = await hub.push(fixture.principal, request); + assertCounts(fixture, second); + assert.deepEqual(second.accepted, first.accepted); + assert.equal((await store.read()).events.length, fixture.want.stored_events); +} + +async function runConflictFixture(fixture) { + const { hub } = openFixtureHub(fixture); + await hub.push(fixture.principal, { + replica_id: fixture.replica_id, + batch_id: `${fixture.name}-first`, + events: await fixtureEvents((fixture.events ?? []).slice(0, 1)) + }); + const response = await hub.push(fixture.principal, { + replica_id: fixture.replica_id, + batch_id: `${fixture.name}-second`, + events: await fixtureEvents((fixture.events ?? []).slice(1)) + }); + assertCounts(fixture, response); +} + +async function runPullFixture(fixture) { + const { hub } = openFixtureHub(fixture); + await seedFixture(hub, fixture); + const response = await hub.pull(fixture.principal, { replica_id: fixture.replica_id }); + assert.equal(response.events.length, fixture.want.events); + if (fixture.want.after_cursor_events >= 0 && response.next_cursor) { + const after = await hub.pull(fixture.principal, { + replica_id: fixture.replica_id, + remote_cursor: response.next_cursor + }); + assert.equal(after.events.length, fixture.want.after_cursor_events); + } +} + +async function runStatusFixture(fixture) { + const { hub } = openFixtureHub(fixture); + await seedFixture(hub, fixture); + const response = await hub.status(fixture.principal); + assert.equal(response.hub_events_received, fixture.want.hub_events_received); +} + +async function runHTTPAuthFixture(fixture) { + const memory = { kind: "memory", id: "project" }; + const handler = createMemoryHandler({ + grants: { "replica-a@team": { principal: "replica-a@team", scopes: [memory] } }, + tokens: { "tok-a": "replica-a@team" }, + store: new MemoryHubStore(), + audit: [] + }); + const body = JSON.stringify({ + replica_id: "local-a", + batch_id: fixture.name, + events: await fixtureEvents([{ + origin_replica_id: "local-a", + local_decision_id: "dec-auth", + resource_ref: memory, + fields: { content: "auth" } + }]) + }); + const request = new Request(`https://mnemon.test${fixture.route}`, { + method: fixture.method, + headers: fixture.token ? { authorization: `Bearer ${fixture.token}` } : {}, + body + }); + const response = await handler(request); + assert.equal(response.status, fixture.want.status_code); +} + +async function seedFixture(hub, fixture) { + if (!fixture.seed_events?.length) { + return; + } + await hub.push(fixture.seed_principal, { + replica_id: fixture.seed_replica_id, + batch_id: `${fixture.name}-seed`, + events: await fixtureEvents(fixture.seed_events) + }); +} + +async function fixtureEvents(fixtures) { + const events = []; + for (const fixture of fixtures) { + const material = await fixtureMaterial(fixture); + if (fixture.corrupt_digest) { + material.fields_digest = "corrupt"; + } + events.push(await syncedEventEnvelopeFromMaterial(material)); + } + return events; +} + +async function fixtureMaterial(fixture) { + const fields = fixture.fields ?? {}; + return { + origin_replica_id: fixture.origin_replica_id, + local_decision_id: fixture.local_decision_id, + local_ingest_seq: 1, + actor: "codex@project", + correlation_id: "", + resource_ref: fixture.resource_ref, + resource_version: 1, + fields_digest: await fieldsDigest(fields), + fields, + decided_at: "2026-06-12T00:00:00Z", + status: "pending" + }; +} + +function assertCounts(fixture, response) { + assert.equal(response.accepted?.length ?? 0, fixture.want.accepted); + assert.equal(response.rejected?.length ?? 0, fixture.want.rejected); + assert.equal(response.conflicts?.length ?? 0, fixture.want.conflicts); +} diff --git a/harness/cloudflare/mnemonhub/wrangler.toml b/harness/cloudflare/mnemonhub/wrangler.toml new file mode 100644 index 00000000..e2f726fa --- /dev/null +++ b/harness/cloudflare/mnemonhub/wrangler.toml @@ -0,0 +1,14 @@ +name = "mnemon-r3-1-hub" +main = "src/index.mjs" +compatibility_date = "2026-07-01" + +[[durable_objects.bindings]] +name = "MNEMON_HUB" +class_name = "MnemonHubDO" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["MnemonHubDO"] + +[vars] +MNEMON_HUB_WORKSPACE = "default" diff --git a/harness/cmd/mnemon-acceptance/acceptance.go b/harness/cmd/mnemon-acceptance/acceptance.go index d3db9968..65d0f83b 100644 --- a/harness/cmd/mnemon-acceptance/acceptance.go +++ b/harness/cmd/mnemon-acceptance/acceptance.go @@ -1185,7 +1185,7 @@ func setupR1CodexSyncAgents(ctx context.Context, runRoot, binDir string, hub r1S if i-1 >= len(hub.Tokens) { return nil, fmt.Errorf("hub token missing for agent %d", i) } - if err := upsertSyncRemote(filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json"), workspace, "hub", exchange.RemoteBackendHTTP, "", hub.URL, "", "", hub.Tokens[i-1], "", ""); err != nil { + if err := upsertSyncRemote(filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json"), workspace, "hub", exchange.RemoteBackendHTTP, "", hub.URL, hub.Tokens[i-1], "", ""); err != nil { return nil, err } loaded, err := access.LoadBindingFile(workspace, filepath.Join(workspace, access.DefaultBindingFile)) diff --git a/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go b/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go deleted file mode 100644 index e6c84342..00000000 --- a/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go +++ /dev/null @@ -1,1711 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/mnemon-dev/mnemon/harness/internal/app" - "github.com/mnemon-dev/mnemon/harness/internal/codexapp" - "github.com/mnemon-dev/mnemon/harness/internal/contract" - "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" - githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" - "github.com/mnemon-dev/mnemon/harness/internal/runtime" - "github.com/spf13/cobra" -) - -var ( - acceptanceGitHubRepo string - acceptanceGitHubTokenFile string - acceptanceGitHubBranchPrefix string - acceptanceGitHubScenarios []string - acceptanceGitHubSyncInterval time.Duration -) - -var ( - r1GitHubMeshRateLimitAPIURL = "https://api.github.com/rate_limit" - r1GitHubMeshAPIBaseURL = "https://api.github.com" - r1GitHubMeshHTTPClient = &http.Client{Timeout: 10 * time.Second} -) - -var acceptanceR1GitHubMeshCmd = &cobra.Command{ - Use: "r1-github-mesh-task-suite", - Short: "Run GitHub-backed Remote Workspace task-suite acceptance", - RunE: func(cmd *cobra.Command, args []string) error { - report, err := runR1GitHubMeshAcceptance(cmd.Context(), r1GitHubMeshAcceptanceOptions{ - r1CodexAcceptanceOptions: r1CodexAcceptanceOptions{ - RunRoot: acceptanceRunRoot, - Command: acceptanceCommand, - CodexHome: acceptanceCodexHome, - Agents: acceptanceAgents, - AgentTurns: acceptanceAgentTurns, - TurnTimeout: acceptanceTurnTimeout, - Stdout: cmd.OutOrStdout(), - Stderr: cmd.ErrOrStderr(), - }, - Repo: acceptanceGitHubRepo, - TokenFile: acceptanceGitHubTokenFile, - BranchPrefix: acceptanceGitHubBranchPrefix, - Scenarios: acceptanceGitHubScenarios, - SyncInterval: acceptanceGitHubSyncInterval, - }) - if report.ReportPath != "" { - fmt.Fprintf(cmd.OutOrStdout(), "acceptance report: %s\n", report.ReportPath) - } - if err != nil { - return err - } - if report.Status != "ok" { - return fmt.Errorf("GitHub mesh task-suite acceptance status: %s", report.Status) - } - return nil - }, -} - -const r1GitHubMeshWorkerWakePrompt = `Check your Mnemon context. If there is governed work for you, act on it through -your own Local Mnemon and record durable progress. If your focus, availability, -or working state changed, update your agent_profile through your own Local Mnemon. -If there is no work for you, answer "no governed work".` - -func init() { - acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceRunRoot, "run-root", "", "acceptance run directory") - acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceCommand, "command", "codex --dangerously-bypass-hook-trust", "Codex CLI command") - acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceCodexHome, "codex-home-source", "", "source CODEX_HOME to copy auth/config from") - acceptanceR1GitHubMeshCmd.Flags().IntVar(&acceptanceAgents, "agents", 5, "number of Codex appservers") - acceptanceR1GitHubMeshCmd.Flags().BoolVar(&acceptanceAgentTurns, "agent-turns", false, "run real model turns that write governed GitHub mesh events") - acceptanceR1GitHubMeshCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per real agent turn") - acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubRepo, "github-repo", "mnemon-dev/mnemon-teamwork-example", "GitHub Remote Workspace repository (owner/name)") - acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubTokenFile, "github-token-file", "", "GitHub token file for publication store access") - acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubBranchPrefix, "github-branch-prefix", "", "GitHub publication branch prefix; empty uses a run-scoped mnemond id prefix") - acceptanceR1GitHubMeshCmd.Flags().StringArrayVar(&acceptanceGitHubScenarios, "scenario", nil, "natural scenario to run; repeatable") - acceptanceR1GitHubMeshCmd.Flags().DurationVar(&acceptanceGitHubSyncInterval, "sync-interval", 30*time.Second, "GitHub sync interval per local mnemond") - rootCmd.AddCommand(acceptanceR1GitHubMeshCmd) -} - -type r1GitHubMeshAcceptanceOptions struct { - r1CodexAcceptanceOptions - Repo string - TokenFile string - BranchPrefix string - Scenarios []string - SyncInterval time.Duration -} - -func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceOptions) (r1CodexAcceptanceReport, error) { - if opts.Stdout == nil { - opts.Stdout = io.Discard - } - if opts.Stderr == nil { - opts.Stderr = io.Discard - } - if opts.Command == "" { - opts.Command = "codex" - } - if opts.Agents < 5 { - opts.Agents = 5 - } - if opts.TurnTimeout <= 0 { - opts.TurnTimeout = 5 * time.Minute - } - if opts.Repo == "" { - opts.Repo = "mnemon-dev/mnemon-teamwork-example" - } - if opts.SyncInterval <= 0 { - opts.SyncInterval = 30 * time.Second - } - started := time.Now().UTC().Truncate(time.Second) - branchPrefix := r1GitHubMeshBranchPrefix(opts.BranchPrefix, started) - runRoot := opts.RunRoot - if runRoot == "" { - runRoot = filepath.Join(".testdata", "r1-github-mesh-task-suite", started.Format("20060102T150405Z")) - } - runRoot, err := filepath.Abs(runRoot) - if err != nil { - return r1CodexAcceptanceReport{}, err - } - report := r1CodexAcceptanceReport{ - SchemaVersion: 1, - Status: "running", - StartedAt: started.Format(time.RFC3339), - RunRoot: runRoot, - Scenario: "github-mesh-task-suite", - AgentTurns: opts.AgentTurns, - LedgerCounts: map[string]int{}, - DerivedEventAudit: map[string]int{}, - Artifacts: map[string]string{}, - Raw: map[string]json.RawMessage{}, - RunnerContract: &r1RunnerContractReport{ - EntrypointProgressBeforeIntegration: -1, - EntrypointProgressAfterIntegration: -1, - WorkerWakePrompt: r1GitHubMeshWorkerWakePrompt, - }, - } - reportPath := filepath.Join(runRoot, "report.json") - report.ReportPath = reportPath - defer func() { - report.FinishedAt = time.Now().UTC().Truncate(time.Second).Format(time.RFC3339) - _ = os.MkdirAll(filepath.Dir(reportPath), 0o755) - data, _ := json.MarshalIndent(report, "", " ") - _ = os.WriteFile(reportPath, append(data, '\n'), 0o644) - }() - if err := prepareR1AcceptanceRunRoot(runRoot); err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - if err := validateR1GitHubMeshSyncInterval(opts); err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - if opts.TokenFile == "" { - err := fmt.Errorf("--github-token-file is required") - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - tokenFile, err := filepath.Abs(opts.TokenFile) - if err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - if _, err := os.Stat(tokenFile); err != nil { - err = fmt.Errorf("github token file: %w", err) - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - report.Artifacts["github_repo"] = opts.Repo - report.Artifacts["github_token_file"] = tokenFile - rateLimit, err := preflightR1GitHubMeshRateLimit(ctx, tokenFile, r1GitHubMeshMinimumRateLimitRemaining(opts)) - if rateLimit.Limit > 0 || !rateLimit.ResetAt.IsZero() { - report.Artifacts["github_rate_limit_remaining"] = fmt.Sprintf("%d", rateLimit.Remaining) - report.Artifacts["github_rate_limit_limit"] = fmt.Sprintf("%d", rateLimit.Limit) - report.Artifacts["github_rate_limit_reset"] = rateLimit.ResetAt.UTC().Format(time.RFC3339) - } - if err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - if err := preflightR1GitHubMeshRepositoryAccess(ctx, opts.Repo, tokenFile); err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - branches := r1GitHubMeshBranches(branchPrefix, opts.Agents) - if err := ensureR1GitHubMeshBranches(ctx, opts.Repo, tokenFile, branches); err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - binDir, err := installAcceptanceHarnessBinary(runRoot) - if err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - sourceCodexHome := resolveSourceCodexHome(opts.CodexHome) - report.Artifacts["codex_home_source"] = sourceCodexHome - report.Artifacts["github_branch_prefix"] = branchPrefix - report.Artifacts["github_sync_interval"] = opts.SyncInterval.String() - initialOnline := opts.Agents - if opts.AgentTurns { - initialOnline = r1GitHubMeshInitialOnline(opts.Agents) - } - report.Artifacts["github_initial_online_agents"] = fmt.Sprintf("%d", initialOnline) - - agents, err := setupR1CodexGitHubMeshAgents(ctx, runRoot, binDir, opts.Repo, tokenFile, branchPrefix, opts.Agents, sourceCodexHome, opts.SyncInterval, initialOnline) - if err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - defer stopR1CodexSyncAgents(agents) - report.Topology = buildR1ProdSimTopology(agents) - report.Topology.MnemonhubInstances = 0 - addR1Assertion(&report, "github-mesh strict per-hostagent mnemond topology", r1GitHubMeshStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) - - syncReport := buildR1GitHubMeshSyncReport(opts.Repo, agents) - report.Sync = syncReport - for _, agent := range agents { - report.Artifacts["mnemond:"+agent.principal] = prodSimMnemondPath(agent) - report.Artifacts["render_audit:"+agent.principal] = agent.renderAuditPath - } - addR1Assertion(&report, "github-mesh no central mnemon-hub endpoint", syncReport.HubURL == "" && syncReport.HubStatus.HubEventsReceived == 0, "backend=github repo-mediated publication") - addR1Assertion(&report, "github-mesh no p2p node discovery", syncReport.NetworkDiscovery == "none" && syncReport.RosterSource == "configured-remotes-json", fmt.Sprintf("roster_source=%s network_discovery=%s", syncReport.RosterSource, syncReport.NetworkDiscovery)) - addR1Assertion(&report, "github-mesh publication branches configured", len(syncReport.PublicationBranches) == opts.Agents && len(syncReport.BranchByAgent) == opts.Agents, fmt.Sprintf("branches=%v", syncReport.PublicationBranches)) - addR1Assertion(&report, "github-mesh remote plans are per-workspace", distinctStrings(syncReport.RemotePlanPaths) && len(syncReport.RemotePlanPaths) == opts.Agents, fmt.Sprintf("remote_plans=%v", syncReport.RemotePlanPaths)) - addR1Assertion(&report, "github-mesh local stores isolated", distinctStrings(syncReport.LocalStorePaths) && len(syncReport.LocalStorePaths) == opts.Agents, fmt.Sprintf("stores=%v", syncReport.LocalStorePaths)) - addR1Assertion(&report, "github-mesh runtime workspaces isolated", distinctStrings(syncReport.RuntimeWorkspaces) && len(syncReport.RuntimeWorkspaces) == opts.Agents, fmt.Sprintf("workspaces=%v", syncReport.RuntimeWorkspaces)) - - if !opts.AgentTurns { - addR1Assertion(&report, "github-mesh real agent turns requested", false, "rerun with --agent-turns") - report.Status = "failed" - return report, fmt.Errorf("GitHub mesh task-suite acceptance requires --agent-turns") - } - for _, i := range r1GitHubMeshLocalOnlineIndexes(agents) { - if err := startR1CodexAppserver(&agents[i].r1CodexAgent, opts.Command); err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - agentReport, raw, err := initializeR1CodexAgent(&agents[i].r1CodexAgent, opts.TurnTimeout) - if err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - report.Agents = append(report.Agents, agentReport) - syncReport.Agents = append(syncReport.Agents, agentReport) - if raw != nil { - report.Raw[agents[i].principal+":hooks"] = raw - } - } - addR1Assertion(&report, "github-mesh initial appservers start/init", len(report.Agents) == initialOnline, fmt.Sprintf("started=%d initial_online=%d requested=%d", len(report.Agents), initialOnline, opts.Agents)) - - run := r1GitHubMeshRun{ - ctx: ctx, - opts: opts, - report: &report, - agents: agents, - runID: started.Format("150405"), - initialOnline: initialOnline, - } - if err := run.bootstrapProfiles(); err != nil { - addR1Error(&report, err) - } - for _, name := range r1GitHubMeshScenarioNames(opts.Scenarios) { - if err := run.runScenario(name); err != nil { - addR1Error(&report, err) - } - } - addR1Assertion(&report, "github-mesh 5/5 appservers start/init", len(report.Agents) == opts.Agents, fmt.Sprintf("started=%d requested=%d", len(report.Agents), opts.Agents)) - addR1Assertion(&report, "github-mesh delayed mnemond join exercised", run.joined || initialOnline == opts.Agents, fmt.Sprintf("initial_online=%d total=%d lifecycle=%v", initialOnline, opts.Agents, syncReport.Lifecycle)) - addR1Assertion(&report, "github-mesh local mnemond leave/restart exercised", run.lifecycleExercised, fmt.Sprintf("lifecycle=%v", syncReport.Lifecycle)) - run.addPostScenarioAssertions() - obs, obsErr := observeAcceptanceRun(runRoot, 1000) - if obsErr == nil { - report.Observability = &obs - populateR1GitHubMeshSyncEvidence(&report, obs) - counts, warnings := r1GitHubMeshAuthoredEventCounts(agents) - if len(counts) == 0 { - counts = r1ClusterActorEventCounts(obs) - } - if len(warnings) > 0 { - report.Observability.Warnings = append(report.Observability.Warnings, warnings...) - } - report.Participants = r1ClusterParticipants(counts, report.Entrypoint) - ok, detail := acceptedR2PayloadShapeAssertion(obs) - addR1Assertion(&report, "github-mesh accepted event payloads are R2 nested", ok, detail) - } else { - addR1Error(&report, obsErr) - addR1Assertion(&report, "github-mesh accepted event payloads are R2 nested", false, obsErr.Error()) - } - report.DerivedEventAudit = prodSimDerivedAudit(agents) - if len(agents) > 0 { - report.LedgerCounts = countR1Ledger(agents[0].localURL, agents[0].r1CodexAgent) - } - addR1Assertion(&report, "github-mesh no shared governed.db", r1GitHubMeshStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) - addR1Assertion(&report, "github-mesh accepted event subjects only", r1SyncEventSubjectsOnlyAccepted(syncReport.AllowedEventSubjects), fmt.Sprintf("subjects=%v", syncReport.AllowedEventSubjects)) - addR1Assertion(&report, "github-mesh report includes publication/import evidence", len(syncReport.PublishedByBranch) == opts.Agents && len(syncReport.ImportedByMnemond) == opts.Agents, fmt.Sprintf("published=%v imported=%v diagnostics=%v", syncReport.PublishedByBranch, syncReport.ImportedByMnemond, syncReport.DiagnosticsByMnemond)) - if len(report.Errors) == 0 && allR1AssertionsPassed(report.Assertions) && allR1GitHubMeshScenariosOK(report.Scenarios, opts.Scenarios) { - syncReport.Status = "ok" - report.Status = "ok" - return report, nil - } - syncReport.Status = "failed" - report.Status = "failed" - return report, fmt.Errorf("GitHub mesh natural task suite failed") -} - -type r1GitHubMeshRun struct { - ctx context.Context - opts r1GitHubMeshAcceptanceOptions - report *r1CodexAcceptanceReport - agents []r1CodexSyncAgent - runID string - initialOnline int - joined bool - lifecycleExercised bool -} - -func r1GitHubMeshScenarioNames(selected []string) []string { - if len(selected) == 0 { - return []string{"onboarding-synthesis", "sync-risk-review", "live-readiness-operator-safety"} - } - out := make([]string, 0, len(selected)) - for _, name := range selected { - name = strings.TrimSpace(name) - if name != "" { - out = append(out, name) - } - } - return out -} - -func allR1GitHubMeshScenariosOK(scenarios []r1TaskSimScenarioReport, selected []string) bool { - want := map[string]bool{} - for _, name := range r1GitHubMeshScenarioNames(selected) { - want[name] = false - } - for _, scenario := range scenarios { - if _, ok := want[scenario.Name]; ok && scenario.Status == "ok" { - want[scenario.Name] = true - } - } - if len(want) == 0 { - return false - } - for _, ok := range want { - if !ok { - return false - } - } - return true -} - -func r1GitHubMeshScenarioSelected(selected []string, name string) bool { - for _, selectedName := range selected { - if selectedName == name { - return true - } - } - return false -} - -func r1GitHubMeshOKScenarioNames(scenarios []r1TaskSimScenarioReport) []string { - out := []string{} - for _, scenario := range scenarios { - if scenario.Status == "ok" && strings.TrimSpace(scenario.Name) != "" { - out = append(out, scenario.Name) - } - } - sort.Strings(out) - return out -} - -func r1GitHubMeshCrossTaskReuseCandidate(name string, priorOK []string) bool { - if name != "sync-risk-review" { - return false - } - return r1GitHubMeshScenarioSelected(priorOK, "onboarding-synthesis") -} - -func r1GitHubMeshHasOKScenarioEvidenceBool(scenarios []r1TaskSimScenarioReport, name, key string) bool { - for _, scenario := range scenarios { - if scenario.Name != name || scenario.Status != "ok" { - continue - } - if value, ok := scenario.Evidence[key].(bool); ok && value { - return true - } - } - return false -} - -func r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(scenarios []r1TaskSimScenarioReport, key string, min int) bool { - for _, scenario := range scenarios { - if scenario.Status != "ok" { - continue - } - switch value := scenario.Evidence[key].(type) { - case int: - if value >= min { - return true - } - case float64: - if int(value) >= min { - return true - } - } - } - return false -} - -func (s *r1GitHubMeshRun) bootstrapProfiles() error { - profileCounts := map[string]int{} - active := r1GitHubMeshReadyAgentIndexes(s.agents) - if len(active) == 0 { - return fmt.Errorf("github mesh profile bootstrap requires at least one online agent") - } - for _, i := range active { - agent := &s.agents[i] - payload := taskSimJSON(map[string]any{ - "rule": map[string]any{ - "actor": agent.principal, - "availability": "available", - "ttl": "30m", - }, - "narrative": map[string]any{ - "focus": fmt.Sprintf("GitHub mesh Remote Workspace acceptance node %s", agent.principal), - "context_advantages": []string{"isolated local mnemond", "github publication branch sync", "real Codex appserver turn"}, - "summary": fmt.Sprintf("%s is available for GitHub mesh teamwork validation.", agent.principal), - }, - }) - prompt := fmt.Sprintf(`Emit exactly one agent_profile.write_candidate.observed event through your own Local Mnemon. -Use external id github-mesh-profile-%s-%s and payload: -%s -After the command succeeds, answer "profile written".`, s.runID, prodSafeID(agent.principal), payload) - recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "profile_bootstrap", prompt) - s.report.RunnerContract.ProfileBootstrapPrompts++ - answer, err := runR1Turn(&agent.r1CodexAgent, prompt, s.opts.TurnTimeout) - appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) - if err != nil { - addR1Assertion(s.report, "github-mesh profile emitted "+agent.principal, false, err.Error()) - return err - } - waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", 1, 20*time.Second) - profileCounts[agent.principal] = countR1Ledger(agent.localURL, agent.r1CodexAgent)["agent_profile"] - } - allVisible := true - for _, i := range active { - agent := s.agents[i] - waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", len(active), 120*time.Second) - counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) - profileCounts[agent.principal] = counts["agent_profile"] - if counts["agent_profile"] < len(active) { - allVisible = false - } - } - addR1Assertion(s.report, "github-mesh initial profiles converge through publication branches", allVisible, fmt.Sprintf("initial_online_agents=%d", len(active))) - s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ - Name: "bootstrap_profiles", - Status: statusFromBool(allVisible), - Evidence: map[string]any{ - "profile_counts_by_agent": profileCounts, - "initial_online_agents": len(active), - "publication_branches": s.report.Sync.PublicationBranches, - }, - }) - if !allVisible { - return fmt.Errorf("profiles did not converge through GitHub publication branches") - } - return nil -} - -func (s *r1GitHubMeshRun) runScenario(name string) error { - entries, err := s.scenarioEntries(name) - if err != nil { - s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{Name: name, Status: "blocked", Evidence: map[string]any{"error": err.Error()}}) - return err - } - var actors []string - var entryTurns []*r1GitHubMeshEntryTurn - for _, entry := range entries { - agent := &s.agents[entry.index] - actors = append(actors, agent.principal) - s.report.RunnerContract.BusinessTaskPrompts++ - if s.report.Entrypoint == "" { - s.report.Entrypoint = agent.principal - s.report.Starter = agent.principal - s.report.Sync.Source = agent.principal - } - recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "natural_user_message:"+name, entry.prompt) - turn, err := startR1GitHubMeshEntryTurn(agent, entry.index, entry.prompt, s.opts.TurnTimeout) - if err != nil { - addR1Assertion(s.report, "github-mesh "+name+" entry "+agent.principal, false, err.Error()) - return err - } - entryTurns = append(entryTurns, turn) - } - seedTimeout := r1GitHubMeshEntrySeedTimeout(s.opts.TurnTimeout) - for _, turn := range entryTurns { - agent := &s.agents[turn.index] - turn.counts, turn.seeded = waitR1GitHubMeshEntrySeed(agent, seedTimeout) - if res, ok := turn.poll(); ok { - appendR1GitHubMeshEntryTurnAnswer(s.report.Sync, turn, res) - if res.Err != nil && !turn.seeded { - addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal, false, fmt.Sprintf("%v counts=%v", res.Err, turn.counts)) - return res.Err - } - } - if !turn.seeded { - err := fmt.Errorf("%s did not publish governed seed events within %s", turn.principal, seedTimeout) - addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal, false, fmt.Sprintf("%v counts=%v", err, turn.counts)) - return err - } - addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal, true, fmt.Sprintf("seeded governed teamwork events counts=%v", turn.counts)) - } - if err := s.wakeWorkers(name, entries); err != nil { - return err - } - priorScenarios := r1GitHubMeshOKScenarioNames(s.report.Scenarios) - busyEntries := map[int]bool{} - for _, turn := range entryTurns { - if res, ok := turn.poll(); ok { - appendR1GitHubMeshEntryTurnAnswer(s.report.Sync, turn, res) - if res.Err != nil { - addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal+" yielded governed seed before timeout", turn.seeded, fmt.Sprintf("%v counts=%v", res.Err, turn.counts)) - if turn.seeded { - if err := s.restartAgentAppserver(turn.index, name, "entry turn timed out after publishing governed seed events"); err != nil { - return err - } - } - } - continue - } - busyEntries[turn.index] = true - } - leadIndex := r1GitHubMeshIntegrationAgentIndex(s.agents, entries, busyEntries) - if leadIndex < 0 { - return fmt.Errorf("github mesh scenario %s has no idle integration agent", name) - } - lead := &s.agents[leadIndex] - integrationPrompt := r1GitHubMeshIntegrationPrompt(name) - s.report.RunnerContract.IntegrationPrompts++ - recordR1ClusterPrompt(s.report.RunnerContract, lead.principal, "integration:"+name, integrationPrompt) - answer, err := runR1Turn(&lead.r1CodexAgent, integrationPrompt, s.opts.TurnTimeout) - appendSyncAgentAnswer(s.report.Sync, lead.principal, answer) - if err != nil { - addR1Assertion(s.report, "github-mesh "+name+" integration", false, err.Error()) - return err - } - for _, turn := range entryTurns { - if turn.pollReady() { - res := turn.wait() - appendR1GitHubMeshEntryTurnAnswer(s.report.Sync, turn, res) - if res.Err != nil && turn.seeded { - addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal+" completed team handoff despite timeout", true, fmt.Sprintf("%v counts=%v", res.Err, turn.counts)) - } - continue - } - if turn.seeded { - addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal+" handed off while still running", true, fmt.Sprintf("counts=%v", turn.counts)) - if err := s.restartAgentAppserver(turn.index, name, "entry turn still running after team integration"); err != nil { - return err - } - } - } - waitR1ClusterAcceptedEventSettle(s.report.RunRoot, 15*time.Second, 2*time.Second) - obs, err := observeAcceptanceRun(s.report.RunRoot, 1000) - if err != nil { - return err - } - counts, countWarnings := r1GitHubMeshAuthoredEventCounts(s.agents) - if len(counts) == 0 { - counts = r1ClusterActorEventCounts(obs) - } - participants := r1ClusterNonProfileParticipantCount(counts) - replans := r1GitHubMeshPromptRounds(s.report.RunnerContract, name) - naturalMessages := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "natural_user_message:"+name) - workerWakes := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "worker_wake:"+name) - integrationPrompts := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "integration:"+name) - assignments := r1GitHubMeshKindTotal(counts, "assignment") - progress := r1GitHubMeshKindTotal(counts, "progress_digest") - signals := r1GitHubMeshKindTotal(counts, "teamwork_signal") - intents := r1GitHubMeshKindTotal(counts, "project_intent") - passed := participants >= 2 && - replans >= 2 && - naturalMessages == len(entries) && - integrationPrompts >= 1 && - s.report.RunnerContract.DirectWorkerBusinessPrompts == 0 && - assignments >= 1 && - progress >= 1 - addR1Assertion(s.report, "github-mesh "+name+" team-shaped multi-round evidence", passed, fmt.Sprintf("participants=%d rounds=%d natural=%d worker_wakes=%d integration=%d assignments=%d progress_digest=%d teamwork_signal=%d project_intent=%d actors=%v", participants, replans, naturalMessages, workerWakes, integrationPrompts, assignments, progress, signals, intents, counts)) - s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ - Name: name, - Status: statusFromBool(passed), - Actors: actors, - Evidence: map[string]any{ - "participants": participants, - "replanning_rounds": replans, - "natural_user_messages": naturalMessages, - "worker_wake_prompts": workerWakes, - "integration_prompts": integrationPrompts, - "entry_poc_agents": actors, - "integration_agent": lead.principal, - "multi_poc": len(actors) > 1, - "prior_ok_scenarios": priorScenarios, - "cross_task_reuse_or_completion": r1GitHubMeshCrossTaskReuseCandidate(name, priorScenarios), - "profile_update_prompted": strings.Contains(r1GitHubMeshWorkerWakePrompt, "agent_profile"), - "direct_worker_business": s.report.RunnerContract.DirectWorkerBusinessPrompts, - "shared_appserver_threads": r1GitHubMeshThreadIDs(s.agents), - "cross_scenario_mnemon_ctx": true, - "actor_event_counts": counts, - "assignment_events": assignments, - "progress_digest_events": progress, - "teamwork_signal_events": signals, - "project_intent_events": intents, - }, - }) - if len(countWarnings) > 0 { - s.report.Scenarios[len(s.report.Scenarios)-1].Evidence["actor_event_count_warnings"] = countWarnings - } - if !passed { - return fmt.Errorf("github mesh scenario %s did not produce team-shaped multi-round evidence", name) - } - return nil -} - -func (s *r1GitHubMeshRun) addPostScenarioAssertions() { - profileCounts := r1GitHubMeshLedgerCountsByAgent(s.agents, "agent_profile") - baseline := len(s.agents) - if s.initialOnline > 0 && s.initialOnline < len(s.agents) { - baseline = s.initialOnline - } - refreshed := false - for _, count := range profileCounts { - if count > baseline { - refreshed = true - break - } - } - addR1Assertion(s.report, "github-mesh profiles refresh during work", refreshed, fmt.Sprintf("agent_profile_counts=%v initial_online_agents=%d total_agents=%d", profileCounts, baseline, len(s.agents))) - if s.report.Sync != nil { - if s.report.Raw == nil { - s.report.Raw = map[string]json.RawMessage{} - } - raw, _ := json.Marshal(profileCounts) - s.report.Raw["github_mesh:profile_counts_after_scenarios"] = raw - } - selected := r1GitHubMeshScenarioNames(s.opts.Scenarios) - if r1GitHubMeshScenarioSelected(selected, "live-readiness-operator-safety") { - addR1Assertion(s.report, "github-mesh multi-poc scenario exercised", r1GitHubMeshHasOKScenarioEvidenceBool(s.report.Scenarios, "live-readiness-operator-safety", "multi_poc"), "scenario=live-readiness-operator-safety") - } - if r1GitHubMeshScenarioSelected(selected, "onboarding-synthesis") && r1GitHubMeshScenarioSelected(selected, "sync-risk-review") { - addR1Assertion(s.report, "github-mesh cross-task reuse/completion evidence recorded", r1GitHubMeshHasOKScenarioEvidenceBool(s.report.Scenarios, "sync-risk-review", "cross_task_reuse_or_completion"), "scenario=sync-risk-review prior=onboarding-synthesis") - } - addR1Assertion(s.report, "github-mesh output-driven replanning evidence recorded", r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(s.report.Scenarios, "replanning_rounds", 2), "requires at least one successful natural scenario with two or more prompt rounds") -} - -type r1GitHubMeshScenarioEntry struct { - index int - prompt string -} - -type r1GitHubMeshTurnResult struct { - Answer string - Err error -} - -type r1GitHubMeshEntryTurn struct { - index int - principal string - before int - done chan r1GitHubMeshTurnResult - result *r1GitHubMeshTurnResult - reported bool - seeded bool - counts map[string]int -} - -func startR1GitHubMeshEntryTurn(agent *r1CodexSyncAgent, index int, prompt string, timeout time.Duration) (*r1GitHubMeshEntryTurn, error) { - server := agent.server - before := server.NotificationCount() - if _, err := server.Request("turn/start", map[string]any{ - "threadId": agent.threadID, - "input": []map[string]any{{"type": "text", "text": prompt}}, - "cwd": agent.workspace, - "approvalPolicy": "never", - "sandboxPolicy": map[string]any{"type": "dangerFullAccess"}, - }, 30*time.Second); err != nil { - return nil, fmt.Errorf("%s: turn/start: %w", agent.principal, err) - } - turn := &r1GitHubMeshEntryTurn{ - index: index, - principal: agent.principal, - before: before, - done: make(chan r1GitHubMeshTurnResult, 1), - } - go func() { - if _, err := server.WaitNotification("turn/completed", timeout, before); err != nil { - text := codexapp.CombinedText(server.NotificationsSince(before)) - turn.done <- r1GitHubMeshTurnResult{ - Answer: truncateR1Cluster(text, 2000), - Err: fmt.Errorf("%s: wait turn/completed: %w", agent.principal, err), - } - return - } - notifications := server.NotificationsSince(before) - answer := codexapp.FinalAnswer(notifications) - if answer == "" { - answer = codexapp.CombinedText(notifications) - } - turn.done <- r1GitHubMeshTurnResult{Answer: answer} - }() - return turn, nil -} - -func (t *r1GitHubMeshEntryTurn) poll() (r1GitHubMeshTurnResult, bool) { - if t == nil { - return r1GitHubMeshTurnResult{}, false - } - if t.result != nil { - return *t.result, true - } - select { - case res := <-t.done: - t.result = &res - return res, true - default: - return r1GitHubMeshTurnResult{}, false - } -} - -func (t *r1GitHubMeshEntryTurn) pollReady() bool { - _, ok := t.poll() - return ok -} - -func (t *r1GitHubMeshEntryTurn) wait() r1GitHubMeshTurnResult { - if res, ok := t.poll(); ok { - return res - } - res := <-t.done - t.result = &res - return res -} - -func appendR1GitHubMeshEntryTurnAnswer(report *r1CodexSyncReport, turn *r1GitHubMeshEntryTurn, res r1GitHubMeshTurnResult) { - if report == nil || turn == nil || turn.reported { - return - } - appendSyncAgentAnswer(report, turn.principal, res.Answer) - turn.reported = true -} - -func r1GitHubMeshEntrySeedTimeout(turnTimeout time.Duration) time.Duration { - if turnTimeout <= 0 { - return 5 * time.Minute - } - return turnTimeout -} - -func waitR1GitHubMeshEntrySeed(agent *r1CodexSyncAgent, timeout time.Duration) (map[string]int, bool) { - deadline := time.Now().Add(timeout) - var counts map[string]int - for time.Now().Before(deadline) { - counts = countR1Ledger(agent.localURL, agent.r1CodexAgent) - if r1GitHubMeshEntrySeedReady(counts) { - return counts, true - } - time.Sleep(500 * time.Millisecond) - } - counts = countR1Ledger(agent.localURL, agent.r1CodexAgent) - return counts, r1GitHubMeshEntrySeedReady(counts) -} - -func r1GitHubMeshEntrySeedReady(counts map[string]int) bool { - return counts["assignment"] >= 1 -} - -func r1GitHubMeshIntegrationAgentIndex(agents []r1CodexSyncAgent, entries []r1GitHubMeshScenarioEntry, busy map[int]bool) int { - if len(entries) > 0 { - idx := entries[0].index - if idx >= 0 && idx < len(agents) && !busy[idx] && r1GitHubMeshAgentReady(agents[idx]) { - return idx - } - } - for i := range agents { - if busy[i] || !r1GitHubMeshAgentReady(agents[i]) { - continue - } - return i - } - return -1 -} - -func (s *r1GitHubMeshRun) restartAgentAppserver(index int, scenario, reason string) error { - if index < 0 || index >= len(s.agents) { - return fmt.Errorf("github mesh restart index out of range: %d", index) - } - agent := &s.agents[index] - if agent.server != nil { - agent.server.Close() - agent.server = nil - } - if err := startR1CodexAppserver(&agent.r1CodexAgent, s.opts.Command); err != nil { - addR1Assertion(s.report, "github-mesh restart appserver "+agent.principal, false, err.Error()) - return err - } - agentReport, raw, err := initializeR1CodexAgent(&agent.r1CodexAgent, s.opts.TurnTimeout) - if err != nil { - addR1Assertion(s.report, "github-mesh restart appserver "+agent.principal, false, err.Error()) - return err - } - if raw != nil && s.report.Raw != nil { - s.report.Raw[agent.principal+":hooks:restart:"+scenario] = raw - } - if s.report.Sync != nil { - s.report.Sync.Lifecycle = append(s.report.Sync.Lifecycle, r1SyncLifecycleReport{ - At: time.Now().UTC().Format(time.RFC3339), - Principal: agent.principal, - Action: "appserver_restart_after_entry_handoff", - Result: "ready", - Branch: s.report.Sync.BranchByAgent[agent.principal], - Detail: reason, - }) - appendSyncAgentAnswer(s.report.Sync, agent.principal, "restarted thread "+agentReport.ThreadID+" after "+reason) - } - addR1Assertion(s.report, "github-mesh restart appserver "+agent.principal, true, reason) - return nil -} - -func (s *r1GitHubMeshRun) scenarioEntries(name string) ([]r1GitHubMeshScenarioEntry, error) { - if len(s.agents) < 5 { - return nil, fmt.Errorf("github mesh natural scenarios require five agents") - } - switch name { - case "onboarding-synthesis": - return []r1GitHubMeshScenarioEntry{{index: 0, prompt: `帮我用团队协作快速理解这个仓库现在的 GitHub Remote Workspace 改造方向,整理一份新成员能读懂的上手说明。请先通过 Mnemon 拉其他成员分别核对架构、测试和风险中的至少两个方向,再继续阅读并根据第一轮反馈做一次补齐或复核后汇总。`}}, nil - case "sync-risk-review": - return []r1GitHubMeshScenarioEntry{{index: 1, prompt: `同步这块我担心还有隐藏问题。请先通过 Mnemon 发起团队协作,检查 GitHub Remote Workspace 相关的配置、诊断和测试设计;把风险排查和验证/补文档拆给合适同伴推进。第一轮先找风险,再根据结果安排第二轮验证或补齐。`}}, nil - case "live-readiness-operator-safety": - return []r1GitHubMeshScenarioEntry{ - {index: 0, prompt: `请你用团队协作推进一次 GitHub live case 的准备,目标是能在 mnemon-dev/mnemon-teamwork-example 上证明 publish/pull/import 成立。请先通过 Mnemon 启动协作,让同伴分别找出实现、测试和运行缺口,再根据第一轮结果安排第二轮补齐。`}, - {index: 2, prompt: `我主要担心这个 GitHub 方案的操作者安全和失败诊断。请你先通过 Mnemon 拉同伴一起从 token、repo、branch、报错可读性这几个角度检查,并把发现反馈给 live case 准备工作。`}, - }, nil - default: - return nil, fmt.Errorf("unknown GitHub mesh scenario %q", name) - } -} - -func (s *r1GitHubMeshRun) wakeWorkers(name string, entries []r1GitHubMeshScenarioEntry) error { - entry := map[int]bool{} - for _, item := range entries { - entry[item.index] = true - } - for cycle := 1; cycle <= 3; cycle++ { - for i := range s.agents { - if entry[i] || !r1GitHubMeshAgentReady(s.agents[i]) { - continue - } - agent := &s.agents[i] - s.report.RunnerContract.WorkerWakePrompts++ - recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "worker_wake:"+name, r1GitHubMeshWorkerWakePrompt) - answer, err := runR1Turn(&agent.r1CodexAgent, r1GitHubMeshWorkerWakePrompt, s.opts.TurnTimeout) - appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) - if err != nil { - s.report.RunnerContract.WorkerWakeErrors = append(s.report.RunnerContract.WorkerWakeErrors, fmt.Sprintf("%s cycle %d %s: %v", name, cycle, agent.principal, err)) - } - } - waitR1ClusterAcceptedEventSettle(s.report.RunRoot, 8*time.Second, 1*time.Second) - if cycle == 1 { - if err := s.joinDelayedAgents(name); err != nil { - return err - } - } - if cycle == 2 && !s.lifecycleExercised { - if err := exerciseR1GitHubMeshLifecycle(s.ctx, s.report, s.agents, s.opts.SyncInterval); err != nil { - return err - } - s.lifecycleExercised = true - } - if cycle >= 2 { - counts, _ := r1GitHubMeshAuthoredEventCounts(s.agents) - if r1GitHubMeshTeamEvidenceCountsReady(counts) { - return nil - } - } - } - return nil -} - -func (s *r1GitHubMeshRun) joinDelayedAgents(scenario string) error { - if s.joined { - return nil - } - var joined []string - profileCounts := map[string]int{} - for i := range s.agents { - if r1GitHubMeshAgentReady(s.agents[i]) { - continue - } - agent := &s.agents[i] - branch := "" - if s.report.Sync != nil { - branch = s.report.Sync.BranchByAgent[agent.principal] - s.report.Sync.Lifecycle = append(s.report.Sync.Lifecycle, r1SyncLifecycleReport{ - At: time.Now().UTC().Format(time.RFC3339), - Principal: agent.principal, - Action: "delayed_join_start", - Result: "requested", - Branch: branch, - Detail: "start a preconfigured isolated Local Mnemon during the natural GitHub mesh task", - }) - } - if err := startR1GitHubMeshLocalMnemond(s.ctx, agent, s.opts.SyncInterval); err != nil { - addR1Assertion(s.report, "github-mesh delayed mnemond join "+agent.principal, false, err.Error()) - return err - } - if err := startR1CodexAppserver(&agent.r1CodexAgent, s.opts.Command); err != nil { - addR1Assertion(s.report, "github-mesh delayed appserver join "+agent.principal, false, err.Error()) - return err - } - agentReport, raw, err := initializeR1CodexAgent(&agent.r1CodexAgent, s.opts.TurnTimeout) - if err != nil { - addR1Assertion(s.report, "github-mesh delayed appserver join "+agent.principal, false, err.Error()) - return err - } - s.report.Agents = append(s.report.Agents, agentReport) - if s.report.Sync != nil { - s.report.Sync.Agents = append(s.report.Sync.Agents, agentReport) - } - if raw != nil { - s.report.Raw[agent.principal+":hooks"] = raw - } - if err := s.emitJoinedProfile(agent, scenario); err != nil { - return err - } - counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) - profileCounts[agent.principal] = counts["agent_profile"] - joined = append(joined, agent.principal) - if s.report.Sync != nil { - s.report.Sync.Lifecycle = append(s.report.Sync.Lifecycle, r1SyncLifecycleReport{ - At: time.Now().UTC().Format(time.RFC3339), - Principal: agent.principal, - Action: "delayed_join_ready", - Result: "ready", - Branch: branch, - Detail: "joined configured publication mesh, initialized appserver, and published fresh profile", - Ledger: counts, - }) - } - } - s.joined = true - if len(joined) == 0 { - return nil - } - allVisible := true - convergeTimeout := r1GitHubMeshProfileConvergenceTimeout(s.opts.SyncInterval) - for _, i := range r1GitHubMeshReadyAgentIndexes(s.agents) { - agent := s.agents[i] - waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", len(s.agents), convergeTimeout) - counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) - profileCounts[agent.principal] = counts["agent_profile"] - if counts["agent_profile"] < len(s.agents) { - allVisible = false - } - } - passed := len(joined) >= 2 && allVisible - addR1Assertion(s.report, "github-mesh two delayed mnemond join and import backlog", passed, fmt.Sprintf("joined=%v profile_counts=%v", joined, profileCounts)) - s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ - Name: "delayed_join_profiles", - Status: statusFromBool(passed), - Actors: joined, - Evidence: map[string]any{ - "scenario": scenario, - "joined_agents": joined, - "profile_counts_by_agent": profileCounts, - "publication_branches": s.report.Sync.PublicationBranches, - }, - }) - if !passed { - return fmt.Errorf("delayed GitHub mesh join did not converge profiles through publication branches") - } - return nil -} - -func r1GitHubMeshProfileConvergenceTimeout(syncInterval time.Duration) time.Duration { - if syncInterval <= 0 { - return 120 * time.Second - } - timeout := syncInterval*4 + 30*time.Second - if timeout < 120*time.Second { - return 120 * time.Second - } - if timeout > 6*time.Minute { - return 6 * time.Minute - } - return timeout -} - -func (s *r1GitHubMeshRun) emitJoinedProfile(agent *r1CodexSyncAgent, scenario string) error { - payload := taskSimJSON(map[string]any{ - "rule": map[string]any{ - "actor": agent.principal, - "availability": "available", - "ttl": "30m", - }, - "narrative": map[string]any{ - "focus": fmt.Sprintf("Joined GitHub mesh task %s with fresh local context", scenario), - "context_advantages": []string{"late join backlog import", "github publication branch sync", "isolated local mnemond"}, - "summary": fmt.Sprintf("%s joined during %s and can pick up governed work from imported context.", agent.principal, scenario), - }, - }) - prompt := fmt.Sprintf(`Emit exactly one agent_profile.write_candidate.observed event through your own Local Mnemon. -Use external id github-mesh-join-profile-%s-%s and payload: -%s -After the command succeeds, answer "joined profile written".`, s.runID, prodSafeID(agent.principal), payload) - recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "delayed_join_profile:"+scenario, prompt) - s.report.RunnerContract.ProfileBootstrapPrompts++ - answer, err := runR1Turn(&agent.r1CodexAgent, prompt, s.opts.TurnTimeout) - appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) - if err != nil { - addR1Assertion(s.report, "github-mesh delayed profile emitted "+agent.principal, false, err.Error()) - return err - } - waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", 1, 20*time.Second) - return nil -} - -func r1GitHubMeshIntegrationPrompt(name string) string { - return fmt.Sprintf(`Read your own Local Mnemon context and integrate the GitHub mesh teamwork scenario %q. -Use only governed Mnemon events as teammate evidence. If first-round output reveals gaps, emit a follow-up assignment before finalizing; otherwise emit a final progress_digest with participants, evidence, gaps, and next action. -Answer with the event-backed result.`, name) -} - -func r1GitHubMeshPromptRounds(contract *r1RunnerContractReport, scenario string) int { - if contract == nil { - return 0 - } - rounds := 0 - for _, prompt := range contract.PromptAudit { - if strings.Contains(prompt.Kind, scenario) { - rounds++ - } - } - return rounds -} - -func r1GitHubMeshPromptKindCount(contract *r1RunnerContractReport, kind string) int { - if contract == nil { - return 0 - } - count := 0 - for _, prompt := range contract.PromptAudit { - if prompt.Kind == kind { - count++ - } - } - return count -} - -func r1GitHubMeshKindTotal(counts map[string]map[string]int, kind string) int { - total := 0 - for _, byKind := range counts { - total += byKind[kind] - } - return total -} - -func r1GitHubMeshTeamEvidenceCountsReady(counts map[string]map[string]int) bool { - return r1ClusterNonProfileParticipantCount(counts) >= 2 && - r1GitHubMeshKindTotal(counts, "assignment") >= 1 && - r1GitHubMeshKindTotal(counts, "progress_digest") >= 1 -} - -func r1GitHubMeshAuthoredEventCounts(agents []r1CodexSyncAgent) (map[string]map[string]int, []string) { - out := map[string]map[string]int{} - var warnings []string - for _, agent := range agents { - path := prodSimMnemondPath(agent) - db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)") - if err != nil { - warnings = append(warnings, fmt.Sprintf("%s open authored event counts: %v", agent.principal, err)) - continue - } - func() { - defer db.Close() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - rows, err := db.QueryContext(ctx, ` -SELECT actor, resource_kind, COUNT(*) -FROM sync_events -WHERE actor <> '' -GROUP BY actor, resource_kind -ORDER BY actor, resource_kind`) - if err != nil { - warnings = append(warnings, fmt.Sprintf("%s query authored event counts: %v", agent.principal, err)) - return - } - defer rows.Close() - for rows.Next() { - var actor, kind string - var count int - if err := rows.Scan(&actor, &kind, &count); err != nil { - warnings = append(warnings, fmt.Sprintf("%s scan authored event counts: %v", agent.principal, err)) - return - } - if out[actor] == nil { - out[actor] = map[string]int{} - } - out[actor][kind] += count - } - if err := rows.Err(); err != nil { - warnings = append(warnings, fmt.Sprintf("%s read authored event counts: %v", agent.principal, err)) - } - }() - } - return out, warnings -} - -func r1GitHubMeshLedgerCountsByAgent(agents []r1CodexSyncAgent, kind string) map[string]int { - out := make(map[string]int, len(agents)) - for _, agent := range agents { - out[agent.principal] = countR1Ledger(agent.localURL, agent.r1CodexAgent)[kind] - } - return out -} - -func r1GitHubMeshThreadIDs(agents []r1CodexSyncAgent) map[string]string { - out := make(map[string]string, len(agents)) - for _, agent := range agents { - if strings.TrimSpace(agent.threadID) != "" { - out[agent.principal] = agent.threadID - } - } - return out -} - -func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, tokenFile, branchPrefix string, count int, sourceCodexHome string, syncInterval time.Duration, initialOnline int) ([]r1CodexSyncAgent, error) { - if syncInterval <= 0 { - syncInterval = 30 * time.Second - } - if initialOnline < 0 || initialOnline > count { - initialOnline = count - } - var agents []r1CodexSyncAgent - branches := r1GitHubMeshBranches(branchPrefix, count) - for i := 1; i <= count; i++ { - principal := fmt.Sprintf("codex-%02d@project", i) - workspace := filepath.Join(runRoot, "workspaces", fmt.Sprintf("codex-%02d", i)) - codexHome := filepath.Join(runRoot, "codex-home", fmt.Sprintf("codex-%02d", i)) - if err := os.MkdirAll(workspace, 0o755); err != nil { - return nil, err - } - if err := os.WriteFile(filepath.Join(workspace, "README.md"), []byte("# R1 GitHub mesh acceptance workspace\n"), 0o644); err != nil { - return nil, err - } - if err := prepareAcceptanceCodexHome(codexHome, workspace, sourceCodexHome); err != nil { - return nil, err - } - localAddr, err := freeLoopbackAddr() - if err != nil { - return nil, err - } - localURL := "http://" + localAddr - if _, err := app.New(workspace).Setup(context.Background(), io.Discard, io.Discard, app.SetupOptions{ - Host: "codex", - ControlURL: localURL, - Principal: principal, - HarnessBin: filepath.Join(binDir, "mnemon-harness"), - ProjectRoot: workspace, - UseToken: true, - }); err != nil { - return nil, err - } - if err := writeR1GitHubMeshRemotes(workspace, repo, tokenFile, branches, i-1); err != nil { - return nil, err - } - loaded, err := access.LoadBindingFile(workspace, filepath.Join(workspace, access.DefaultBindingFile)) - if err != nil { - return nil, err - } - token, err := acceptanceTokenForPrincipal(loaded.Tokens, contract.ActorID(principal)) - if err != nil { - return nil, err - } - agent := r1CodexSyncAgent{ - r1CodexAgent: r1CodexAgent{ - principal: principal, - workspace: workspace, - codexHome: codexHome, - token: token, - env: acceptanceEnv(binDir, codexHome, runRoot), - }, - localURL: localURL, - replicaPrincipal: principal, - replicaToken: tokenFile, - renderAuditPath: filepath.Join(workspace, ".mnemon", "harness", "local", "render-audit.jsonl"), - } - if i <= initialOnline { - if err := startR1GitHubMeshLocalMnemond(ctx, &agent, syncInterval); err != nil { - return nil, err - } - } - agents = append(agents, agent) - } - return agents, nil -} - -func exerciseR1GitHubMeshLifecycle(ctx context.Context, report *r1CodexAcceptanceReport, agents []r1CodexSyncAgent, syncInterval time.Duration) error { - if report == nil || report.Sync == nil || len(agents) < 5 { - return nil - } - if syncInterval <= 0 { - syncInterval = 30 * time.Second - } - target := &agents[2] - branch := report.Sync.BranchByAgent[target.principal] - report.Sync.Lifecycle = append(report.Sync.Lifecycle, r1SyncLifecycleReport{ - At: time.Now().UTC().Format(time.RFC3339), - Principal: target.principal, - Action: "pause_local_mnemond", - Result: "requested", - Branch: branch, - Detail: "cancel one isolated Local Mnemon before teamwork turns; appserver remains initialized", - }) - if target.localCancel == nil { - addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", false, "target has no localCancel") - return fmt.Errorf("%s has no local mnemond cancel function", target.principal) - } - target.localCancel() - if target.localErr != nil { - stopTimeout := 45 * time.Second - select { - case <-target.localErr: - case <-time.After(stopTimeout): - addR1Assertion(report, "github-mesh local mnemond pause observed", false, "timeout waiting for local mnemond stop after "+stopTimeout.String()) - return fmt.Errorf("%s local mnemond did not stop within %s", target.principal, stopTimeout) - } - } - target.localCancel = nil - target.localErr = nil - if err := startR1GitHubMeshLocalMnemond(ctx, target, syncInterval); err != nil { - addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", false, err.Error()) - return err - } - counts := countR1Ledger(target.localURL, target.r1CodexAgent) - report.Sync.Lifecycle = append(report.Sync.Lifecycle, r1SyncLifecycleReport{ - At: time.Now().UTC().Format(time.RFC3339), - Principal: target.principal, - Action: "restart_local_mnemond", - Result: "ready", - Branch: branch, - Detail: "restarted the same isolated Local Mnemon store/workspace and configured GitHub publication branch", - Ledger: counts, - }) - addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", true, fmt.Sprintf("principal=%s branch=%s", target.principal, branch)) - return nil -} - -func startR1GitHubMeshLocalMnemond(ctx context.Context, agent *r1CodexSyncAgent, syncInterval time.Duration) error { - if agent == nil { - return fmt.Errorf("nil GitHub mesh agent") - } - if agent.localCancel != nil { - return nil - } - if syncInterval <= 0 { - syncInterval = 30 * time.Second - } - loaded, err := access.LoadBindingFile(agent.workspace, filepath.Join(agent.workspace, access.DefaultBindingFile)) - if err != nil { - return err - } - addr := strings.TrimPrefix(agent.localURL, "http://") - if strings.TrimSpace(addr) == "" { - return fmt.Errorf("%s has no local URL", agent.principal) - } - localCtx, cancel := context.WithCancel(ctx) - localErr := make(chan error, 1) - go func(workspace, addr string, loaded access.LoadedBindings) { - localErr <- app.RunLocalHTTPServerWithBindings(localCtx, addr, filepath.Join(workspace, runtime.DefaultStorePath), loaded, app.ServeOptions{ - ProjectRoot: workspace, - SyncInterval: syncInterval, - }, io.Discard) - }(agent.workspace, addr, loaded) - agent.localCancel = cancel - agent.localErr = localErr - if err := waitR1LocalReady(ctx, agent.r1CodexAgent, agent.localURL, 10*time.Second); err != nil { - cancel() - agent.localCancel = nil - agent.localErr = nil - return err - } - return nil -} - -func r1GitHubMeshInitialOnline(count int) int { - if count <= 2 { - return count - } - if count <= 5 { - return count - 2 - } - return count - 2 -} - -func r1GitHubMeshAgentReady(agent r1CodexSyncAgent) bool { - return agent.localCancel != nil && agent.server != nil && strings.TrimSpace(agent.threadID) != "" -} - -func r1GitHubMeshLocalOnlineIndexes(agents []r1CodexSyncAgent) []int { - out := []int{} - for i, agent := range agents { - if agent.localCancel != nil { - out = append(out, i) - } - } - return out -} - -func r1GitHubMeshReadyAgentIndexes(agents []r1CodexSyncAgent) []int { - out := []int{} - for i, agent := range agents { - if r1GitHubMeshAgentReady(agent) { - out = append(out, i) - } - } - return out -} - -func writeR1GitHubMeshRemotes(workspace, repo, tokenFile string, branches []string, self int) error { - if self < 0 || self >= len(branches) { - return fmt.Errorf("self index %d outside branches", self) - } - repo, err := exchange.NormalizeGitHubRepo(repo) - if err != nil { - return err - } - if strings.TrimSpace(tokenFile) == "" { - return fmt.Errorf("github token file is required") - } - normalized := make([]string, 0, len(branches)) - for _, branch := range branches { - branch, err := exchange.NormalizePublicationBranch(branch) - if err != nil { - return err - } - normalized = append(normalized, branch) - } - branches = normalized - remotesPath := filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json") - if err := upsertSyncRemote(remotesPath, workspace, "self", exchange.RemoteBackendGitHub, exchange.RemoteDirectionPublish, "", repo, branches[self], "", tokenFile, ""); err != nil { - return err - } - for i, branch := range branches { - if i == self { - continue - } - id := fmt.Sprintf("stream-%02d", i+1) - if err := upsertSyncRemote(remotesPath, workspace, id, exchange.RemoteBackendGitHub, exchange.RemoteDirectionSubscribe, "", repo, branch, "", tokenFile, ""); err != nil { - return err - } - } - return nil -} - -func r1GitHubMeshBranches(prefix string, count int) []string { - if prefix == "" { - prefix = "mnemon/mnemond-" - } - out := make([]string, 0, count) - for i := 0; i < count; i++ { - suffix := fmt.Sprintf("%02d", i+1) - if strings.HasSuffix(prefix, "-") && i < 26 { - suffix = string(rune('a' + i)) - } - out = append(out, prefix+suffix) - } - return out -} - -func r1GitHubMeshBranchPrefix(prefix string, started time.Time) string { - prefix = strings.TrimSpace(prefix) - if prefix != "" { - return prefix - } - return "mnemon/mnemond-" + started.UTC().Format("20060102T150405Z") + "-" -} - -func validateR1GitHubMeshSyncInterval(opts r1GitHubMeshAcceptanceOptions) error { - if !opts.AgentTurns { - return nil - } - if opts.SyncInterval < 30*time.Second { - return fmt.Errorf("github mesh agent-turns require --sync-interval >= 30s to protect GitHub API quota (got %s)", opts.SyncInterval) - } - return nil -} - -func ensureR1GitHubMeshBranches(ctx context.Context, repo, tokenFile string, branches []string) error { - token, err := readR1GitHubMeshToken(tokenFile) - if err != nil { - return err - } - store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ - Repo: repo, - Token: token, - MutativeDelay: time.Second, - }) - if err != nil { - return err - } - if err := store.EnsureBranches(ctx, branches, "main"); err != nil { - return fmt.Errorf("ensure GitHub branches: %w", err) - } - return nil -} - -type r1GitHubMeshRateLimit struct { - Limit int - Remaining int - Used int - ResetAt time.Time -} - -func r1GitHubMeshMinimumRateLimitRemaining(opts r1GitHubMeshAcceptanceOptions) int { - scenarios := len(r1GitHubMeshScenarioNames(opts.Scenarios)) - if scenarios == 0 { - scenarios = 1 - } - agents := opts.Agents - if agents < 5 { - agents = 5 - } - min := 500 + agents*scenarios*150 - if min < 1500 { - return 1500 - } - return min -} - -func preflightR1GitHubMeshRateLimit(ctx context.Context, tokenFile string, minRemaining int) (r1GitHubMeshRateLimit, error) { - token, err := readR1GitHubMeshToken(tokenFile) - if err != nil { - return r1GitHubMeshRateLimit{}, err - } - limit, err := fetchR1GitHubMeshRateLimit(ctx, token) - if err != nil { - return r1GitHubMeshRateLimit{}, err - } - if limit.Remaining < minRemaining { - return limit, fmt.Errorf("github core API rate limit remaining %d below required %d; reset=%s", limit.Remaining, minRemaining, limit.ResetAt.UTC().Format(time.RFC3339)) - } - return limit, nil -} - -func preflightR1GitHubMeshRepositoryAccess(ctx context.Context, repo, tokenFile string) error { - token, err := readR1GitHubMeshToken(tokenFile) - if err != nil { - return err - } - return fetchR1GitHubMeshRepositoryAccess(ctx, repo, token) -} - -func fetchR1GitHubMeshRepositoryAccess(ctx context.Context, repo, token string) error { - repo, err := exchange.NormalizeGitHubRepo(repo) - if err != nil { - return err - } - baseURL := strings.TrimRight(strings.TrimSpace(r1GitHubMeshAPIBaseURL), "/") - if baseURL == "" { - baseURL = "https://api.github.com" - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/repos/"+repo, nil) - if err != nil { - return err - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - req.Header.Set("User-Agent", "mnemon-acceptance") - if strings.TrimSpace(token) != "" { - req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) - } - client := r1GitHubMeshHTTPClient - if client == nil { - client = http.DefaultClient - } - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - if resp.StatusCode >= 300 { - return fmt.Errorf("github repo access status %d for %s: %s", resp.StatusCode, repo, strings.TrimSpace(string(body))) - } - return nil -} - -func fetchR1GitHubMeshRateLimit(ctx context.Context, token string) (r1GitHubMeshRateLimit, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, r1GitHubMeshRateLimitAPIURL, nil) - if err != nil { - return r1GitHubMeshRateLimit{}, err - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - req.Header.Set("User-Agent", "mnemon-acceptance") - if strings.TrimSpace(token) != "" { - req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) - } - client := r1GitHubMeshHTTPClient - if client == nil { - client = http.DefaultClient - } - resp, err := client.Do(req) - if err != nil { - return r1GitHubMeshRateLimit{}, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return r1GitHubMeshRateLimit{}, err - } - if resp.StatusCode >= 300 { - return r1GitHubMeshRateLimit{}, fmt.Errorf("github rate_limit status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - var doc struct { - Resources struct { - Core struct { - Limit int `json:"limit"` - Remaining int `json:"remaining"` - Reset int64 `json:"reset"` - Used int `json:"used"` - } `json:"core"` - } `json:"resources"` - } - if err := json.Unmarshal(body, &doc); err != nil { - return r1GitHubMeshRateLimit{}, fmt.Errorf("parse github rate_limit response: %w", err) - } - core := doc.Resources.Core - return r1GitHubMeshRateLimit{ - Limit: core.Limit, - Remaining: core.Remaining, - Used: core.Used, - ResetAt: time.Unix(core.Reset, 0).UTC(), - }, nil -} - -func readR1GitHubMeshToken(tokenFile string) (string, error) { - body, err := os.ReadFile(tokenFile) - if err != nil { - return "", fmt.Errorf("read github token file: %w", err) - } - token := strings.TrimSpace(string(body)) - if token == "" { - return "", fmt.Errorf("github token file is empty") - } - return token, nil -} - -func buildR1GitHubMeshSyncReport(repo string, agents []r1CodexSyncAgent) *r1CodexSyncReport { - report := &r1CodexSyncReport{ - Status: "running", - Backend: exchange.RemoteBackendGitHub, - Repo: repo, - TransportModel: "repo-mediated-publication", - RosterSource: "configured-remotes-json", - NetworkDiscovery: "none", - AllowedEventSubjects: r1SyncEventSubjectLabels(r1GitHubMeshScopes()), - Artifacts: map[string]string{}, - BranchByAgent: map[string]string{}, - PublishedByBranch: map[string]int{}, - ImportedByMnemond: map[string]int{}, - DiagnosticsByMnemond: map[string]int{}, - ProfileByMnemond: map[string]int{}, - } - for i, agent := range agents { - branch := "" - if remote, err := exchange.LoadRemoteEntry(filepath.Join(agent.workspace, ".mnemon", "harness", "sync", "remotes.json"), "self"); err == nil { - branch = remote.Branch - } else if agent.replicaPrincipal != "" { - branch = agent.replicaPrincipal - } - if branch != "" { - report.PublicationBranches = append(report.PublicationBranches, branch) - report.BranchByAgent[agent.principal] = branch - } - report.RuntimeWorkspaces = append(report.RuntimeWorkspaces, agent.workspace) - report.LocalStorePaths = append(report.LocalStorePaths, filepath.Join(agent.workspace, runtime.DefaultStorePath)) - remotesPath := filepath.Join(agent.workspace, ".mnemon", "harness", "sync", "remotes.json") - report.RemotePlanPaths = append(report.RemotePlanPaths, remotesPath) - report.Artifacts[fmt.Sprintf("remotes:%s", agent.principal)] = remotesPath - if i == 0 { - report.Source = agent.principal - } - } - sort.Strings(report.PublicationBranches) - sort.Strings(report.RemotePlanPaths) - return report -} - -func populateR1GitHubMeshSyncEvidence(report *r1CodexAcceptanceReport, obs acceptanceObserveReport) { - if report == nil || report.Sync == nil { - return - } - syncReport := report.Sync - if syncReport.PublishedByBranch == nil { - syncReport.PublishedByBranch = map[string]int{} - } - if syncReport.ImportedByMnemond == nil { - syncReport.ImportedByMnemond = map[string]int{} - } - if syncReport.DiagnosticsByMnemond == nil { - syncReport.DiagnosticsByMnemond = map[string]int{} - } - if syncReport.ProfileByMnemond == nil { - syncReport.ProfileByMnemond = map[string]int{} - } - stores := map[string]acceptanceStoreInspect{} - for _, store := range obs.Stores { - if store.Role == "mnemond" { - stores[store.Name] = store - } - } - for _, agent := range syncReport.Agents { - principal := strings.TrimSpace(agent.Principal) - if principal == "" { - continue - } - storeName := r1GitHubMeshStoreName(principal) - store, ok := stores[storeName] - if !ok { - continue - } - branch := syncReport.BranchByAgent[principal] - if branch != "" { - syncReport.PublishedByBranch[branch] = store.SyncEventsByStatus["synced"] - } - syncReport.ImportedByMnemond[principal] = store.Counts["imported_accepted"] - syncReport.DiagnosticsByMnemond[principal] = store.ObservedByType["sync.diagnostic"] + store.ObservedByType["sync.remote_diagnostic.observed"] - syncReport.ProfileByMnemond[principal] = store.EnvelopeByType["agent_profile.accepted"] - } -} - -func r1GitHubMeshStoreName(principal string) string { - name, _, _ := strings.Cut(strings.TrimSpace(principal), "@") - return name -} - -func r1GitHubMeshStrictTopology(top *r1AcceptanceTopologyReport) bool { - if top == nil || top.Mode != "per-hostagent-mnemond" || top.SharedMnemond || top.MnemonhubInstances != 0 || top.Agents < 5 || top.MnemondInstances != top.Agents { - return false - } - seen := map[string]bool{} - for _, path := range top.AgentMnemondMap { - if strings.TrimSpace(path) == "" || seen[path] { - return false - } - seen[path] = true - } - return len(seen) == top.Agents -} - -func r1GitHubMeshScopes() []contract.ResourceRef { - return []contract.ResourceRef{ - {Kind: "agent_profile", ID: "project"}, - {Kind: "project_intent", ID: "project"}, - {Kind: "teamwork_signal", ID: "project"}, - {Kind: "assignment", ID: "project"}, - {Kind: "progress_digest", ID: "project"}, - } -} - -func distinctStrings(values []string) bool { - seen := map[string]bool{} - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" || seen[value] { - return false - } - seen[value] = true - } - return true -} diff --git a/harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go b/harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go deleted file mode 100644 index c9ab9027..00000000 --- a/harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go +++ /dev/null @@ -1,609 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/mnemon-dev/mnemon/harness/internal/codexapp" - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" - "github.com/mnemon-dev/mnemon/harness/internal/runtime" -) - -func TestR1GitHubMeshBranchesDefaultShape(t *testing.T) { - got := r1GitHubMeshBranches("mnemon/mnemond-", 5) - want := []string{"mnemon/mnemond-a", "mnemon/mnemond-b", "mnemon/mnemond-c", "mnemon/mnemond-d", "mnemon/mnemond-e"} - if len(got) != len(want) { - t.Fatalf("branches = %v, want %v", got, want) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("branches = %v, want %v", got, want) - } - } -} - -func TestR1GitHubMeshBranchPrefixDefaultsToRunScopedBranches(t *testing.T) { - started := time.Date(2026, 6, 25, 18, 57, 20, 0, time.UTC) - prefix := r1GitHubMeshBranchPrefix("", started) - if prefix != "mnemon/mnemond-20260625T185720Z-" { - t.Fatalf("prefix = %q, want run-scoped mnemond prefix", prefix) - } - got := r1GitHubMeshBranches(prefix, 2) - want := []string{ - "mnemon/mnemond-20260625T185720Z-a", - "mnemon/mnemond-20260625T185720Z-b", - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("branches = %v, want %v", got, want) - } - } - if explicit := r1GitHubMeshBranchPrefix("mnemon/mnemond-team-", started); explicit != "mnemon/mnemond-team-" { - t.Fatalf("explicit prefix = %q, want unchanged", explicit) - } -} - -func TestFetchR1GitHubMeshRateLimit(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("Authorization"); got != "Bearer secret-token" { - t.Fatalf("authorization header = %q", got) - } - _, _ = w.Write([]byte(`{"resources":{"core":{"limit":5000,"remaining":4321,"reset":1782725122,"used":679}}}`)) - })) - defer server.Close() - oldURL := r1GitHubMeshRateLimitAPIURL - oldClient := r1GitHubMeshHTTPClient - r1GitHubMeshRateLimitAPIURL = server.URL - r1GitHubMeshHTTPClient = server.Client() - t.Cleanup(func() { - r1GitHubMeshRateLimitAPIURL = oldURL - r1GitHubMeshHTTPClient = oldClient - }) - - limit, err := fetchR1GitHubMeshRateLimit(context.Background(), "secret-token") - if err != nil { - t.Fatal(err) - } - if limit.Limit != 5000 || limit.Remaining != 4321 || limit.Used != 679 || limit.ResetAt.Unix() != 1782725122 { - t.Fatalf("rate limit mismatch: %+v", limit) - } -} - -func TestPreflightR1GitHubMeshRateLimitBlocksLowRemaining(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"resources":{"core":{"limit":5000,"remaining":42,"reset":1782725122,"used":4958}}}`)) - })) - defer server.Close() - oldURL := r1GitHubMeshRateLimitAPIURL - oldClient := r1GitHubMeshHTTPClient - r1GitHubMeshRateLimitAPIURL = server.URL - r1GitHubMeshHTTPClient = server.Client() - t.Cleanup(func() { - r1GitHubMeshRateLimitAPIURL = oldURL - r1GitHubMeshHTTPClient = oldClient - }) - tokenFile := filepath.Join(t.TempDir(), "github.token") - if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { - t.Fatal(err) - } - limit, err := preflightR1GitHubMeshRateLimit(context.Background(), tokenFile, 1500) - if err == nil || !strings.Contains(err.Error(), "below required 1500") { - t.Fatalf("expected low-rate-limit error, got limit=%+v err=%v", limit, err) - } - if limit.Remaining != 42 { - t.Fatalf("rate limit should be returned for diagnostics: %+v", limit) - } -} - -func TestR1GitHubMeshAcceptanceBlocksInvalidRepositoryTokenBeforeSetup(t *testing.T) { - tmp := t.TempDir() - var repoChecked bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("Authorization"); got != "Bearer secret-token" { - t.Fatalf("authorization header = %q", got) - } - switch r.URL.Path { - case "/rate_limit": - _, _ = w.Write([]byte(`{"resources":{"core":{"limit":5000,"remaining":5000,"reset":1782725122,"used":0}}}`)) - case "/repos/mnemon-dev/mnemon-teamwork-example": - repoChecked = true - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"message":"Bad credentials"}`)) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer server.Close() - oldRateLimitURL := r1GitHubMeshRateLimitAPIURL - oldAPIBaseURL := r1GitHubMeshAPIBaseURL - oldClient := r1GitHubMeshHTTPClient - r1GitHubMeshRateLimitAPIURL = server.URL + "/rate_limit" - r1GitHubMeshAPIBaseURL = server.URL - r1GitHubMeshHTTPClient = server.Client() - t.Cleanup(func() { - r1GitHubMeshRateLimitAPIURL = oldRateLimitURL - r1GitHubMeshAPIBaseURL = oldAPIBaseURL - r1GitHubMeshHTTPClient = oldClient - }) - - tokenFile := filepath.Join(tmp, "github.token") - if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { - t.Fatal(err) - } - report, err := runR1GitHubMeshAcceptance(context.Background(), r1GitHubMeshAcceptanceOptions{ - r1CodexAcceptanceOptions: r1CodexAcceptanceOptions{ - RunRoot: filepath.Join(tmp, "run"), - AgentTurns: true, - TurnTimeout: time.Millisecond, - }, - Repo: "mnemon-dev/mnemon-teamwork-example", - TokenFile: tokenFile, - SyncInterval: 30 * time.Second, - }) - if err == nil || !strings.Contains(err.Error(), "github repo access status 401") || !strings.Contains(err.Error(), "Bad credentials") { - t.Fatalf("expected invalid repo token blocker, got report=%+v err=%v", report, err) - } - if !repoChecked { - t.Fatal("repository access preflight was not called") - } - if report.Status != "blocked" || !strings.Contains(strings.Join(report.Errors, "\n"), "Bad credentials") { - t.Fatalf("report should be blocked with repo credential error: %+v", report) - } - if _, err := os.Stat(filepath.Join(report.RunRoot, "bin")); !os.IsNotExist(err) { - t.Fatalf("acceptance should block before installing binaries, stat err=%v", err) - } - data, err := os.ReadFile(report.ReportPath) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), "github repo access status 401") { - t.Fatalf("written report missing repo access blocker:\n%s", data) - } -} - -func TestValidateR1GitHubMeshSyncIntervalProtectsAgentTurns(t *testing.T) { - err := validateR1GitHubMeshSyncInterval(r1GitHubMeshAcceptanceOptions{ - r1CodexAcceptanceOptions: r1CodexAcceptanceOptions{AgentTurns: true}, - SyncInterval: 10 * time.Second, - }) - if err == nil || !strings.Contains(err.Error(), ">= 30s") { - t.Fatalf("expected short sync interval guard, got %v", err) - } - if err := validateR1GitHubMeshSyncInterval(r1GitHubMeshAcceptanceOptions{ - r1CodexAcceptanceOptions: r1CodexAcceptanceOptions{AgentTurns: true}, - SyncInterval: 30 * time.Second, - }); err != nil { - t.Fatalf("30s sync interval should be allowed: %v", err) - } - if err := validateR1GitHubMeshSyncInterval(r1GitHubMeshAcceptanceOptions{SyncInterval: 10 * time.Second}); err != nil { - t.Fatalf("short non-agent-turn sync interval should be allowed: %v", err) - } -} - -func TestWriteR1GitHubMeshRemotesCreatesPublishAndSubscribePlan(t *testing.T) { - root := t.TempDir() - tokenFile := filepath.Join(root, "github.token") - if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { - t.Fatal(err) - } - workspace := filepath.Join(root, "workspaces", "codex-03") - if err := os.MkdirAll(workspace, 0o755); err != nil { - t.Fatal(err) - } - branches := r1GitHubMeshBranches("mnemon/mnemond-", 5) - if err := writeR1GitHubMeshRemotes(workspace, "mnemon-dev/mnemon-teamwork-example", tokenFile, branches, 2); err != nil { - t.Fatalf("write github mesh remotes: %v", err) - } - - remotesPath := filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json") - plan, err := exchange.LoadRemotePlan(remotesPath, "default") - if err != nil { - t.Fatalf("load remote plan: %v", err) - } - if len(plan.PushTargets) != 1 || plan.PushTargets[0].ID != "self" || plan.PushTargets[0].Branch != "mnemon/mnemond-c" { - t.Fatalf("push targets = %+v, want self on mnemon/mnemond-c", plan.PushTargets) - } - if len(plan.PullSources) != 4 { - t.Fatalf("pull sources = %+v, want four peer streams", plan.PullSources) - } - for _, remote := range append(plan.PushTargets, plan.PullSources...) { - if remote.Backend != exchange.RemoteBackendGitHub || - remote.Repo != "mnemon-dev/mnemon-teamwork-example" || - remote.CredentialRef != tokenFile || - remote.Endpoint != "" { - t.Fatalf("remote not a github publication stream: %+v", remote) - } - } -} - -func TestSetupR1CodexGitHubMeshAgentsCanDelayLocalMnemondStart(t *testing.T) { - root := t.TempDir() - tokenFile := filepath.Join(root, "github.token") - if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { - t.Fatal(err) - } - agents, err := setupR1CodexGitHubMeshAgents(context.Background(), root, root, "mnemon-dev/mnemon-teamwork-example", tokenFile, "mnemon/mnemond-", 5, "", 30*time.Second, 0) - if err != nil { - t.Fatalf("setup delayed github mesh agents: %v", err) - } - if len(agents) != 5 { - t.Fatalf("agents = %d, want 5", len(agents)) - } - if got := r1GitHubMeshLocalOnlineIndexes(agents); len(got) != 0 { - t.Fatalf("local online indexes = %v, want none before delayed start", got) - } - for i, agent := range agents { - if agent.localCancel != nil || agent.localErr != nil { - t.Fatalf("agent %d local mnemond should not be started", i) - } - remotesPath := filepath.Join(agent.workspace, ".mnemon", "harness", "sync", "remotes.json") - plan, err := exchange.LoadRemotePlan(remotesPath, "default") - if err != nil { - t.Fatalf("load delayed remote plan %d: %v", i, err) - } - if len(plan.PushTargets) != 1 || len(plan.PullSources) != 4 { - t.Fatalf("remote plan %d = %+v, want one publish and four subscribe streams", i, plan) - } - } -} - -func TestR1GitHubMeshInitialOnlineLeavesTwoDelayedAgents(t *testing.T) { - if got := r1GitHubMeshInitialOnline(5); got != 3 { - t.Fatalf("initial online for 5 = %d, want 3", got) - } - if got := r1GitHubMeshInitialOnline(7); got != 5 { - t.Fatalf("initial online for 7 = %d, want 5", got) - } - agents := []r1CodexSyncAgent{ - {localCancel: func() {}}, - {}, - {localCancel: func() {}}, - } - got := r1GitHubMeshLocalOnlineIndexes(agents) - if len(got) != 2 || got[0] != 0 || got[1] != 2 { - t.Fatalf("local online indexes = %v, want [0 2]", got) - } -} - -func TestBuildR1GitHubMeshSyncReportProvesIsolationAndNoHub(t *testing.T) { - root := t.TempDir() - tokenFile := filepath.Join(root, "github.token") - if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { - t.Fatal(err) - } - branches := r1GitHubMeshBranches("mnemon/mnemond-", 3) - agents := make([]r1CodexSyncAgent, 0, 3) - for i := range branches { - workspace := filepath.Join(root, "workspaces", branches[i][len("mnemon/"):]) - if err := os.MkdirAll(workspace, 0o755); err != nil { - t.Fatal(err) - } - if err := writeR1GitHubMeshRemotes(workspace, "mnemon-dev/mnemon-teamwork-example", tokenFile, branches, i); err != nil { - t.Fatal(err) - } - agents = append(agents, r1CodexSyncAgent{r1CodexAgent: r1CodexAgent{ - principal: "codex-0" + string(rune('1'+i)) + "@project", - workspace: workspace, - }}) - } - - report := buildR1GitHubMeshSyncReport("mnemon-dev/mnemon-teamwork-example", agents) - if report.Backend != exchange.RemoteBackendGitHub || report.Repo != "mnemon-dev/mnemon-teamwork-example" || report.HubURL != "" { - t.Fatalf("report backend/repo/hub wrong: %+v", report) - } - if report.TransportModel != "repo-mediated-publication" || report.RosterSource != "configured-remotes-json" || report.NetworkDiscovery != "none" { - t.Fatalf("report must pin github bootstrap semantics without p2p discovery: %+v", report) - } - if len(report.PublicationBranches) != 3 || len(report.BranchByAgent) != 3 { - t.Fatalf("report branches wrong: %+v", report) - } - if len(report.RemotePlanPaths) != 3 || !distinctStrings(report.RemotePlanPaths) { - t.Fatalf("report must expose one remotes.json per workspace, got %+v", report.RemotePlanPaths) - } - if !distinctStrings(report.RuntimeWorkspaces) || !distinctStrings(report.LocalStorePaths) { - t.Fatalf("report must prove isolated workspaces/stores: workspaces=%v stores=%v", report.RuntimeWorkspaces, report.LocalStorePaths) - } - for i, storePath := range report.LocalStorePaths { - want := filepath.Join(agents[i].workspace, runtime.DefaultStorePath) - if storePath != want { - t.Fatalf("store path[%d] = %q, want %q", i, storePath, want) - } - } -} - -func TestR1GitHubMeshStrictTopologyRequiresNoHub(t *testing.T) { - top := &r1AcceptanceTopologyReport{ - Mode: "per-hostagent-mnemond", - Agents: 5, - MnemondInstances: 5, - MnemonhubInstances: 0, - SharedMnemond: false, - AgentMnemondMap: map[string]string{ - "a": "/tmp/a.db", - "b": "/tmp/b.db", - "c": "/tmp/c.db", - "d": "/tmp/d.db", - "e": "/tmp/e.db", - }, - } - if !r1GitHubMeshStrictTopology(top) { - t.Fatalf("github mesh topology should pass without a central hub: %+v", top) - } - top.MnemonhubInstances = 1 - if r1GitHubMeshStrictTopology(top) { - t.Fatal("github mesh topology must reject central mnemon-hub instances") - } -} - -func TestPopulateR1GitHubMeshSyncEvidence(t *testing.T) { - report := &r1CodexAcceptanceReport{Sync: &r1CodexSyncReport{ - Agents: []r1CodexAgentReport{ - {Principal: "codex-01@project"}, - {Principal: "codex-02@project"}, - }, - BranchByAgent: map[string]string{ - "codex-01@project": "mnemon/mnemond-run-a", - "codex-02@project": "mnemon/mnemond-run-b", - }, - }} - obs := acceptanceObserveReport{Stores: []acceptanceStoreInspect{ - { - Name: "codex-01", - Role: "mnemond", - Counts: map[string]int{"imported_accepted": 4}, - SyncEventsByStatus: map[string]int{"synced": 3}, - ObservedByType: map[string]int{"sync.diagnostic": 1}, - EnvelopeByType: map[string]int{"agent_profile.accepted": 5}, - }, - { - Name: "codex-02", - Role: "mnemond", - Counts: map[string]int{"imported_accepted": 2}, - SyncEventsByStatus: map[string]int{"synced": 1}, - ObservedByType: map[string]int{"sync.remote_diagnostic.observed": 2}, - EnvelopeByType: map[string]int{"agent_profile.accepted": 3}, - }, - }} - - populateR1GitHubMeshSyncEvidence(report, obs) - - if got := report.Sync.PublishedByBranch["mnemon/mnemond-run-a"]; got != 3 { - t.Fatalf("published branch a = %d, want 3", got) - } - if got := report.Sync.ImportedByMnemond["codex-02@project"]; got != 2 { - t.Fatalf("imported codex-02 = %d, want 2", got) - } - if got := report.Sync.DiagnosticsByMnemond["codex-02@project"]; got != 2 { - t.Fatalf("diagnostics codex-02 = %d, want 2", got) - } - if got := report.Sync.ProfileByMnemond["codex-01@project"]; got != 5 { - t.Fatalf("profiles codex-01 = %d, want 5", got) - } -} - -func TestR1GitHubMeshScenarioContract(t *testing.T) { - names := r1GitHubMeshScenarioNames(nil) - want := []string{"onboarding-synthesis", "sync-risk-review", "live-readiness-operator-safety"} - if len(names) != len(want) { - t.Fatalf("default scenarios = %v, want %v", names, want) - } - for i := range want { - if names[i] != want[i] { - t.Fatalf("default scenarios = %v, want %v", names, want) - } - } - run := r1GitHubMeshRun{agents: make([]r1CodexSyncAgent, 5)} - entries, err := run.scenarioEntries("live-readiness-operator-safety") - if err != nil { - t.Fatalf("multi-poc scenario entries: %v", err) - } - if len(entries) != 2 || entries[0].index == entries[1].index { - t.Fatalf("live-readiness scenario must use two distinct PoC agents, got %+v", entries) - } - for _, entry := range entries { - for _, forbidden := range []string{"Agent A must", "Agent B must", "must create assignments for"} { - if strings.Contains(entry.prompt, forbidden) { - t.Fatalf("natural prompt contains forced choreography %q: %s", forbidden, entry.prompt) - } - } - } - if !strings.Contains(r1GitHubMeshWorkerWakePrompt, "agent_profile") { - t.Fatal("github mesh worker wake prompt should let agents naturally refresh their profile") - } -} - -func TestR1GitHubMeshScenarioStatusRequiresSelectedOK(t *testing.T) { - scenarios := []r1TaskSimScenarioReport{ - {Name: "onboarding-synthesis", Status: "ok"}, - {Name: "sync-risk-review", Status: "failed"}, - {Name: "live-readiness-operator-safety", Status: "ok"}, - } - if allR1GitHubMeshScenariosOK(scenarios, nil) { - t.Fatal("default scenario set must require every selected scenario to be ok") - } - scenarios[1].Status = "ok" - if !allR1GitHubMeshScenariosOK(scenarios, nil) { - t.Fatal("default scenario set should pass when every default scenario is ok") - } - if !allR1GitHubMeshScenariosOK(scenarios[:1], []string{"onboarding-synthesis"}) { - t.Fatal("explicit scenario selection should require only the selected scenario") - } -} - -func TestR1GitHubMeshNaturalSuiteEvidenceHelpers(t *testing.T) { - scenarios := []r1TaskSimScenarioReport{ - {Name: "onboarding-synthesis", Status: "ok", Evidence: map[string]any{"replanning_rounds": 3}}, - {Name: "sync-risk-review", Status: "ok", Evidence: map[string]any{"cross_task_reuse_or_completion": true}}, - {Name: "live-readiness-operator-safety", Status: "ok", Evidence: map[string]any{"multi_poc": true}}, - } - prior := r1GitHubMeshOKScenarioNames(scenarios[:1]) - if !r1GitHubMeshCrossTaskReuseCandidate("sync-risk-review", prior) { - t.Fatal("sync-risk-review should be a cross-task reuse candidate after onboarding-synthesis") - } - if r1GitHubMeshCrossTaskReuseCandidate("sync-risk-review", nil) { - t.Fatal("sync-risk-review without prior onboarding should not claim cross-task reuse") - } - if !r1GitHubMeshHasOKScenarioEvidenceBool(scenarios, "live-readiness-operator-safety", "multi_poc") { - t.Fatal("multi-poc evidence should be detected on the successful live-readiness scenario") - } - if !r1GitHubMeshHasOKScenarioEvidenceBool(scenarios, "sync-risk-review", "cross_task_reuse_or_completion") { - t.Fatal("cross-task evidence should be detected on the successful sync-risk scenario") - } - if !r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(scenarios, "replanning_rounds", 2) { - t.Fatal("replanning evidence should accept int counts") - } - scenarios[0].Evidence["replanning_rounds"] = float64(2) - if !r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(scenarios, "replanning_rounds", 2) { - t.Fatal("replanning evidence should accept json-decoded float64 counts") - } - scenarios[2].Status = "failed" - if r1GitHubMeshHasOKScenarioEvidenceBool(scenarios, "live-readiness-operator-safety", "multi_poc") { - t.Fatal("failed scenarios must not satisfy evidence assertions") - } -} - -func TestR1GitHubMeshPromptRoundsCountsScenarioPrompts(t *testing.T) { - contract := &r1RunnerContractReport{} - recordR1ClusterPrompt(contract, "codex-01@project", "natural_user_message:onboarding-synthesis", "prompt") - recordR1ClusterPrompt(contract, "codex-02@project", "worker_wake:onboarding-synthesis", "wake") - recordR1ClusterPrompt(contract, "codex-01@project", "integration:onboarding-synthesis", "integrate") - recordR1ClusterPrompt(contract, "codex-01@project", "integration:other", "other") - if got := r1GitHubMeshPromptRounds(contract, "onboarding-synthesis"); got != 3 { - t.Fatalf("prompt rounds = %d, want 3", got) - } - if got := r1GitHubMeshPromptKindCount(contract, "worker_wake:onboarding-synthesis"); got != 1 { - t.Fatalf("worker wake prompt count = %d, want 1", got) - } -} - -func TestR1GitHubMeshEntrySeedTimeoutIsBounded(t *testing.T) { - if got := r1GitHubMeshEntrySeedTimeout(8 * time.Minute); got != 8*time.Minute { - t.Fatalf("seed timeout = %s, want full turn timeout", got) - } - if got := r1GitHubMeshEntrySeedTimeout(2 * time.Minute); got != 2*time.Minute { - t.Fatalf("short seed timeout = %s, want full short turn timeout", got) - } - if got := r1GitHubMeshEntrySeedTimeout(0); got != 5*time.Minute { - t.Fatalf("default seed timeout = %s, want 5m", got) - } -} - -func TestR1GitHubMeshEntrySeedReadyUsesAssignment(t *testing.T) { - if !r1GitHubMeshEntrySeedReady(map[string]int{"assignment": 1}) { - t.Fatal("assignment should be enough to seed worker handoff") - } - if r1GitHubMeshEntrySeedReady(map[string]int{"teamwork_signal": 1}) { - t.Fatal("signal without assignment should not seed worker handoff") - } -} - -func TestR1GitHubMeshProfileConvergenceTimeoutScalesWithSyncInterval(t *testing.T) { - if got := r1GitHubMeshProfileConvergenceTimeout(30 * time.Second); got != 150*time.Second { - t.Fatalf("30s sync convergence timeout = %s, want 150s", got) - } - if got := r1GitHubMeshProfileConvergenceTimeout(90 * time.Second); got != 6*time.Minute { - t.Fatalf("90s sync convergence timeout = %s, want capped 6m", got) - } - if got := r1GitHubMeshProfileConvergenceTimeout(0); got != 120*time.Second { - t.Fatalf("default convergence timeout = %s, want 120s", got) - } -} - -func TestR1GitHubMeshIntegrationAgentSkipsBusyEntrypoint(t *testing.T) { - agents := []r1CodexSyncAgent{ - {r1CodexAgent: r1CodexAgent{principal: "codex-01@project", server: codexapp.New("codex", t.TempDir()), threadID: "thread-1"}, localCancel: func() {}}, - {r1CodexAgent: r1CodexAgent{principal: "codex-02@project", server: codexapp.New("codex", t.TempDir()), threadID: "thread-2"}, localCancel: func() {}}, - {r1CodexAgent: r1CodexAgent{principal: "codex-03@project", server: codexapp.New("codex", t.TempDir()), threadID: "thread-3"}, localCancel: func() {}}, - } - entries := []r1GitHubMeshScenarioEntry{{index: 0}} - if got := r1GitHubMeshIntegrationAgentIndex(agents, entries, nil); got != 0 { - t.Fatalf("integration agent = %d, want entrypoint when idle", got) - } - if got := r1GitHubMeshIntegrationAgentIndex(agents, entries, map[int]bool{0: true}); got != 1 { - t.Fatalf("integration agent = %d, want first idle teammate", got) - } - if got := r1GitHubMeshIntegrationAgentIndex(agents, entries, map[int]bool{0: true, 1: true, 2: true}); got != -1 { - t.Fatalf("integration agent = %d, want none when all ready agents are busy", got) - } -} - -func TestR1GitHubMeshKindTotalCountsGovernedEvents(t *testing.T) { - counts := map[string]map[string]int{ - "codex-01@project": {"assignment": 2, "progress_digest": 1}, - "codex-02@project": {"assignment": 1, "teamwork_signal": 1}, - } - if got := r1GitHubMeshKindTotal(counts, "assignment"); got != 3 { - t.Fatalf("assignment total = %d, want 3", got) - } - if got := r1GitHubMeshKindTotal(counts, "progress_digest"); got != 1 { - t.Fatalf("progress total = %d, want 1", got) - } - if got := r1GitHubMeshKindTotal(counts, "project_intent"); got != 0 { - t.Fatalf("missing kind total = %d, want 0", got) - } -} - -func TestR1GitHubMeshTeamEvidenceCountsReady(t *testing.T) { - counts := map[string]map[string]int{ - "codex-01@project": {"assignment": 2}, - "codex-02@project": {"progress_digest": 1}, - "codex-03@project": {"progress_digest": 1}, - } - if !r1GitHubMeshTeamEvidenceCountsReady(counts) { - t.Fatalf("team evidence should be ready for two non-profile participants: %+v", counts) - } - counts = map[string]map[string]int{ - "codex-01@project": {"assignment": 2}, - "codex-02@project": {"agent_profile": 1}, - } - if r1GitHubMeshTeamEvidenceCountsReady(counts) { - t.Fatalf("team evidence should require progress beyond assignment publication: %+v", counts) - } -} - -func TestR1GitHubMeshAuthoredEventCountsUseLocalSyncEvents(t *testing.T) { - root := t.TempDir() - workspace := filepath.Join(root, "workspaces", "codex-01") - storePath := filepath.Join(workspace, runtime.DefaultStorePath) - if err := os.MkdirAll(filepath.Dir(storePath), 0o755); err != nil { - t.Fatal(err) - } - db, err := sql.Open("sqlite", storePath) - if err != nil { - t.Fatal(err) - } - defer db.Close() - if _, err := db.Exec(`CREATE TABLE sync_events(actor TEXT, resource_kind TEXT, status TEXT)`); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`INSERT INTO sync_events(actor, resource_kind, status) VALUES - ('codex-01@project', 'assignment', 'synced'), - ('codex-01@project', 'assignment', 'pending'), - ('codex-02@project', 'progress_digest', 'synced'), - ('', 'progress_digest', 'synced')`); err != nil { - t.Fatal(err) - } - - counts, warnings := r1GitHubMeshAuthoredEventCounts([]r1CodexSyncAgent{{ - r1CodexAgent: r1CodexAgent{principal: "codex-01@project", workspace: workspace}, - }}) - if len(warnings) != 0 { - t.Fatalf("warnings = %v", warnings) - } - if got := counts["codex-01@project"]["assignment"]; got != 2 { - t.Fatalf("codex-01 assignment count = %d, want 2", got) - } - if got := counts["codex-02@project"]["progress_digest"]; got != 1 { - t.Fatalf("codex-02 progress count = %d, want 1", got) - } - if _, ok := counts[""]; ok { - t.Fatal("blank actors must not be counted") - } -} diff --git a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go index b584cda3..35407032 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go +++ b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go @@ -19,11 +19,9 @@ import ( ) var ( - acceptanceManagedExchange string - acceptanceManagedMnemondBin string - acceptanceManagedRuntimeAdapter string - acceptanceManagedGitHubRepo string - acceptanceManagedGitHubTokenFile string + acceptanceManagedExchange string + acceptanceManagedMnemondBin string + acceptanceManagedRuntimeAdapter string ) var acceptanceManagedRuntimeCmd = &cobra.Command{ @@ -31,19 +29,17 @@ var acceptanceManagedRuntimeCmd = &cobra.Command{ Short: "Run managed-runtime seed-and-observe acceptance", RunE: func(cmd *cobra.Command, args []string) error { report, err := runManagedRuntimeAcceptance(cmd.Context(), managedRuntimeAcceptanceOptions{ - RunRoot: acceptanceRunRoot, - Command: acceptanceCommand, - CodexHome: acceptanceCodexHome, - Agents: acceptanceAgents, - AgentTurns: acceptanceAgentTurns, - Exchange: acceptanceManagedExchange, - MnemondBin: acceptanceManagedMnemondBin, - Runtime: acceptanceManagedRuntimeAdapter, - GitHubRepo: acceptanceManagedGitHubRepo, - GitHubTokenFile: acceptanceManagedGitHubTokenFile, - TurnTimeout: acceptanceTurnTimeout, - Stdout: cmd.OutOrStdout(), - Stderr: cmd.ErrOrStderr(), + RunRoot: acceptanceRunRoot, + Command: acceptanceCommand, + CodexHome: acceptanceCodexHome, + Agents: acceptanceAgents, + AgentTurns: acceptanceAgentTurns, + Exchange: acceptanceManagedExchange, + MnemondBin: acceptanceManagedMnemondBin, + Runtime: acceptanceManagedRuntimeAdapter, + TurnTimeout: acceptanceTurnTimeout, + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), }) if report.ReportPath != "" { fmt.Fprintf(cmd.OutOrStdout(), "acceptance report: %s\n", report.ReportPath) @@ -64,30 +60,26 @@ func init() { acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceCodexHome, "codex-home-source", "", "source CODEX_HOME to copy auth/config from") acceptanceManagedRuntimeCmd.Flags().IntVar(&acceptanceAgents, "agents", 5, "number of managed agent nodes") acceptanceManagedRuntimeCmd.Flags().BoolVar(&acceptanceAgentTurns, "agent-turns", false, "run real managed Codex turns after the seed") - acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedExchange, "exchange", "mnemonhub", "exchange mode: mnemonhub or github") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedExchange, "exchange", "mnemonhub", "exchange mode: mnemonhub") acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedMnemondBin, "mnemond-bin", "mnemond", "mnemond binary used for product-path wake checks") acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedRuntimeAdapter, "runtime", "codex-appserver", "managed runtime adapter: codex-appserver or noop") - acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedGitHubRepo, "github-repo", "mnemon-dev/mnemon-teamwork-example", "GitHub Remote Workspace repository (owner/name)") - acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedGitHubTokenFile, "github-token-file", "", "GitHub token file for real GitHub exchange validation") acceptanceManagedRuntimeCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per managed wake check") rootCmd.AddCommand(acceptanceManagedRuntimeCmd) } type managedRuntimeAcceptanceOptions struct { - RunRoot string - Command string - CodexHome string - Agents int - AgentTurns bool - Exchange string - MnemondBin string - Runtime string - GitHubRepo string - GitHubTokenFile string - TurnTimeout time.Duration - Stdout io.Writer - Stderr io.Writer - Wake managedRuntimeWakeFunc + RunRoot string + Command string + CodexHome string + Agents int + AgentTurns bool + Exchange string + MnemondBin string + Runtime string + TurnTimeout time.Duration + Stdout io.Writer + Stderr io.Writer + Wake managedRuntimeWakeFunc } type managedRuntimeWakeFunc func(context.Context, managedRuntimeAcceptanceOptions, string) (string, error) @@ -98,7 +90,6 @@ type managedRuntimeAcceptanceReport struct { Layer string `json:"layer"` RunnerRole string `json:"runner_role"` Exchange string `json:"exchange"` - GitHubRepo string `json:"github_repo,omitempty"` Runtime string `json:"runtime"` StartedAt string `json:"started_at"` FinishedAt string `json:"finished_at"` @@ -152,9 +143,6 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta if opts.TurnTimeout <= 0 { opts.TurnTimeout = 5 * time.Minute } - if strings.TrimSpace(opts.GitHubRepo) == "" { - opts.GitHubRepo = "mnemon-dev/mnemon-teamwork-example" - } exchangeMode := strings.TrimSpace(opts.Exchange) if exchangeMode == "" { exchangeMode = "mnemonhub" @@ -178,7 +166,6 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta Layer: "managed_runtime_acceptance", RunnerRole: "seed_and_observe", Exchange: exchangeMode, - GitHubRepo: strings.TrimSpace(opts.GitHubRepo), Runtime: runtimeName, StartedAt: started.Format(time.RFC3339), RunRoot: runRoot, @@ -189,8 +176,8 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta report.Errors = append(report.Errors, err.Error()) return finishManagedRuntimeReport(report), err } - if exchangeMode != "mnemonhub" && exchangeMode != "github" { - err := fmt.Errorf("managed-runtime acceptance exchange must be mnemonhub or github, got %q", exchangeMode) + if exchangeMode != "mnemonhub" { + err := fmt.Errorf("managed-runtime acceptance exchange must be mnemonhub, got %q", exchangeMode) report.Status = "blocked" report.Errors = append(report.Errors, err.Error()) written, writeErr := finishAndWriteManagedRuntimeReport(report) @@ -204,17 +191,6 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta report.Errors = append(report.Errors, err.Error()) return finishManagedRuntimeReport(report), err } - if exchangeMode == "github" && strings.TrimSpace(opts.GitHubTokenFile) == "" { - err := fmt.Errorf("managed-runtime github acceptance requires --github-token-file for real GitHub access") - report.Status = "blocked" - report.Errors = append(report.Errors, err.Error()) - addManagedAssertion(&report, "github token file provided", false, err.Error()) - written, writeErr := finishAndWriteManagedRuntimeReport(report) - if writeErr != nil { - return written, writeErr - } - return written, err - } if opts.Wake != nil { return runManagedRuntimeInjectedWakeAcceptance(ctx, opts, report) } @@ -229,14 +205,7 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta } return written, err } - switch exchangeMode { - case "mnemonhub": - return runManagedRuntimeMnemonhubAcceptance(ctx, opts, report) - case "github": - return runManagedRuntimeGitHubAcceptance(ctx, opts, report) - default: - panic("validated exchange mode escaped switch") - } + return runManagedRuntimeMnemonhubAcceptance(ctx, opts, report) } func runManagedRuntimeInjectedWakeAcceptance(ctx context.Context, opts managedRuntimeAcceptanceOptions, report managedRuntimeAcceptanceReport) (managedRuntimeAcceptanceReport, error) { @@ -293,45 +262,6 @@ func runManagedRuntimeMnemonhubAcceptance(ctx context.Context, opts managedRunti return runManagedRuntimeRealScenario(ctx, opts, report, agents) } -func runManagedRuntimeGitHubAcceptance(ctx context.Context, opts managedRuntimeAcceptanceOptions, report managedRuntimeAcceptanceReport) (managedRuntimeAcceptanceReport, error) { - tokenFile, err := filepath.Abs(opts.GitHubTokenFile) - if err != nil { - return managedRuntimeBlocked(report, err) - } - if _, err := os.Stat(tokenFile); err != nil { - return managedRuntimeBlocked(report, fmt.Errorf("github token file: %w", err)) - } - binDir, err := installAcceptanceHarnessBinary(report.RunRoot) - if err != nil { - return managedRuntimeBlocked(report, err) - } - started, _ := time.Parse(time.RFC3339, report.StartedAt) - branchPrefix := r1GitHubMeshBranchPrefix("", started) - branches := r1GitHubMeshBranches(branchPrefix, opts.Agents) - if err := ensureR1GitHubMeshBranches(ctx, report.GitHubRepo, tokenFile, branches); err != nil { - return managedRuntimeBlocked(report, err) - } - sourceCodexHome := resolveSourceCodexHome(opts.CodexHome) - report.Artifacts["codex_home_source"] = sourceCodexHome - report.Artifacts["github_repo"] = report.GitHubRepo - report.Artifacts["github_token_file"] = tokenFile - report.Artifacts["github_branch_prefix"] = branchPrefix - agents, err := setupR1CodexGitHubMeshAgents(ctx, report.RunRoot, binDir, report.GitHubRepo, tokenFile, branchPrefix, opts.Agents, sourceCodexHome, 30*time.Second, opts.Agents) - if err != nil { - return managedRuntimeBlocked(report, err) - } - defer stopR1CodexSyncAgents(agents) - report.Topology = buildR1ProdSimTopology(agents) - report.Topology.MnemonhubInstances = 0 - addManagedAssertion(&report, "managed-runtime github per-hostagent mnemond topology", r1GitHubMeshStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) - addManagedAssertion(&report, "managed-runtime github has no central hub", report.Topology.MnemonhubInstances == 0, "backend=github") - for _, agent := range agents { - report.Artifacts["mnemond:"+agent.principal] = prodSimMnemondPath(agent) - report.Artifacts["render_audit:"+agent.principal] = agent.renderAuditPath - } - return runManagedRuntimeRealScenario(ctx, opts, report, agents) -} - func runManagedRuntimeRealScenario(ctx context.Context, opts managedRuntimeAcceptanceOptions, report managedRuntimeAcceptanceReport, agents []r1CodexSyncAgent) (managedRuntimeAcceptanceReport, error) { if len(agents) < 5 { return managedRuntimeFailed(report, fmt.Errorf("managed-runtime requires at least 5 agents, got %d", len(agents))) diff --git a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go index d7688f45..67e77ac6 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go +++ b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go @@ -67,7 +67,7 @@ func TestManagedRuntimeAcceptanceRejectsNonSentinelWake(t *testing.T) { } } -func TestManagedRuntimeGitHubRequiresTokenFile(t *testing.T) { +func TestManagedRuntimeRejectsGitHubExchange(t *testing.T) { report, err := runManagedRuntimeAcceptance(context.Background(), managedRuntimeAcceptanceOptions{ RunRoot: t.TempDir(), Agents: 1, @@ -77,10 +77,10 @@ func TestManagedRuntimeGitHubRequiresTokenFile(t *testing.T) { }, }) if err == nil { - t.Fatal("github managed acceptance without token file should return an explicit blocker") + t.Fatal("github managed acceptance should be unsupported") } - if report.Status != "blocked" || !strings.Contains(err.Error(), "--github-token-file") { - t.Fatalf("github blocker mismatch: status=%s err=%v report=%+v", report.Status, err, report) + if report.Status != "blocked" || !strings.Contains(err.Error(), "exchange must be mnemonhub") { + t.Fatalf("github exchange blocker mismatch: status=%s err=%v report=%+v", report.Status, err, report) } } diff --git a/harness/cmd/mnemon-acceptance/root_test.go b/harness/cmd/mnemon-acceptance/root_test.go index d4279366..c11de5c9 100644 --- a/harness/cmd/mnemon-acceptance/root_test.go +++ b/harness/cmd/mnemon-acceptance/root_test.go @@ -13,7 +13,6 @@ func TestRootExposesAcceptanceScenarioCommands(t *testing.T) { "r1-prod-sim", "r1-task-sim", "r1-cluster-single-entrypoint", - "r1-github-mesh-task-suite", "multica-provision", "multica-runtime-prod-sim", } { diff --git a/harness/cmd/mnemon-acceptance/sync_remote.go b/harness/cmd/mnemon-acceptance/sync_remote.go index 4a37723f..d91364b1 100644 --- a/harness/cmd/mnemon-acceptance/sync_remote.go +++ b/harness/cmd/mnemon-acceptance/sync_remote.go @@ -10,7 +10,7 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" ) -func upsertSyncRemote(path, root, id, backend, direction, endpoint, repo, branch, token, tokenFile, caFile string) error { +func upsertSyncRemote(path, root, id, backend, direction, endpoint, token, tokenFile, caFile string) error { doc := exchange.RemotesDoc{SchemaVersion: 1} if raw, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(raw))) > 0 { if err := json.Unmarshal(raw, &doc); err != nil { @@ -31,8 +31,6 @@ func upsertSyncRemote(path, root, id, backend, direction, endpoint, repo, branch Direction: direction, ID: id, Endpoint: endpoint, - Repo: repo, - Branch: branch, CredentialRef: credentialRef, CAFile: normalizeSyncFileRef(caFile), } diff --git a/harness/cmd/mnemon-harness/config_daemon_test.go b/harness/cmd/mnemon-harness/config_daemon_test.go index cc65d0f6..0b5582cb 100644 --- a/harness/cmd/mnemon-harness/config_daemon_test.go +++ b/harness/cmd/mnemon-harness/config_daemon_test.go @@ -87,9 +87,8 @@ func TestDaemonStatusShowsConfiguredRoleSummary(t *testing.T) { root := t.TempDir() cfg := productconfig.Default() cfg.Connections.Multica = productconfig.MulticaConnection{Enabled: true, Workspace: "ws-multica", RuntimeBinary: "mnemon-multica-runtime"} - cfg.Connections.GitHub = productconfig.GitHubConnection{Enabled: true, Repo: "mnemon-dev/mnemon-teamwork-example"} cfg.Connections.Mnemonhub = productconfig.MnemonhubConnection{Enabled: true, Endpoint: "https://hub.example.invalid"} - cfg.Daemon.InteractionWatchers = []string{productconfig.ConnectionMultica, productconfig.ConnectionGitHub, productconfig.ConnectionMnemonhub} + cfg.Daemon.InteractionWatchers = []string{productconfig.ConnectionMultica, productconfig.ConnectionMnemonhub} cfg.Daemon.DriveSources = []string{productconfig.DriveManagedLocal} cfg.Daemon.DisplaySurfaces = []string{productconfig.ConnectionMultica} if err := productconfig.Save(productconfig.DefaultPath(root, ""), cfg); err != nil { @@ -106,10 +105,9 @@ func TestDaemonStatusShowsConfiguredRoleSummary(t *testing.T) { got := out.String() for _, want := range []string{ "Harness config: configured", - "Harness daemon roles: watchers=3 drive=1 surfaces=1", + "Harness daemon roles: watchers=2 drive=1 surfaces=1", "Harness daemon role details:", "multica-watch [interaction]: watcher=multica boundary=activation-carrier", - "github-watch [interaction]: watcher=github boundary=external-interaction", "mnemonhub-watch [interaction]: watcher=mnemonhub boundary=remote-exchange", "managed-drive [drive]: drive=managed-local boundary=managed-runtime", "multica-display [surface]: surface=multica boundary=display-surface", @@ -286,19 +284,15 @@ func TestConnectCommandsWriteProductConfig(t *testing.T) { root := t.TempDir() oldRoot, oldPath := connectRoot, connectConfigPath oldWorkspace, oldRuntime := connectMulticaWS, connectMulticaRuntime - oldRepo, oldBranch := connectGitHubRepo, connectGitHubBranch oldEndpoint := connectMnemonhubURL connectRoot = root connectConfigPath = "" connectMulticaWS = "teamwork-grivn" connectMulticaRuntime = "mnemon-multica-runtime" - connectGitHubRepo = "mnemon-dev/mnemon-teamwork-example" - connectGitHubBranch = "mnemond-planner" connectMnemonhubURL = "https://hub.example.invalid" t.Cleanup(func() { connectRoot, connectConfigPath = oldRoot, oldPath connectMulticaWS, connectMulticaRuntime = oldWorkspace, oldRuntime - connectGitHubRepo, connectGitHubBranch = oldRepo, oldBranch connectMnemonhubURL = oldEndpoint }) @@ -306,9 +300,6 @@ func TestConnectCommandsWriteProductConfig(t *testing.T) { if err := runConnectMultica(cmd, nil); err != nil { t.Fatal(err) } - if err := runConnectGitHub(cmd, nil); err != nil { - t.Fatal(err) - } if err := runConnectMnemonhub(cmd, nil); err != nil { t.Fatal(err) } @@ -320,18 +311,15 @@ func TestConnectCommandsWriteProductConfig(t *testing.T) { if got := cfg.Connections.Multica.RuntimeBinary; got != "mnemon-multica-runtime" { t.Fatalf("unexpected runtime binary: %q", got) } - if !cfg.Connections.GitHub.Enabled || cfg.Connections.GitHub.Repo != "mnemon-dev/mnemon-teamwork-example" { - t.Fatalf("github connection not written: %+v", cfg.Connections.GitHub) - } if !cfg.Connections.Mnemonhub.Enabled || cfg.Connections.Mnemonhub.Endpoint != "https://hub.example.invalid" { t.Fatalf("mnemonhub connection not written: %+v", cfg.Connections.Mnemonhub) } - for _, want := range []string{productconfig.ConnectionMultica, productconfig.ConnectionGitHub, productconfig.ConnectionMnemonhub} { + for _, want := range []string{productconfig.ConnectionMultica, productconfig.ConnectionMnemonhub} { if !containsString(cfg.Daemon.InteractionWatchers, want) { t.Fatalf("interaction watcher %q missing: %+v", want, cfg.Daemon.InteractionWatchers) } } - for _, want := range []string{productconfig.ConnectionMultica, productconfig.ConnectionGitHub} { + for _, want := range []string{productconfig.ConnectionMultica} { if !containsString(cfg.Daemon.DisplaySurfaces, want) { t.Fatalf("display surface %q missing: %+v", want, cfg.Daemon.DisplaySurfaces) } diff --git a/harness/cmd/mnemon-harness/connect.go b/harness/cmd/mnemon-harness/connect.go index 760dc2b6..f0cc37dc 100644 --- a/harness/cmd/mnemon-harness/connect.go +++ b/harness/cmd/mnemon-harness/connect.go @@ -13,8 +13,6 @@ var ( connectConfigPath string connectMulticaWS string connectMulticaRuntime string - connectGitHubRepo string - connectGitHubBranch string connectMnemonhubURL string ) @@ -29,12 +27,6 @@ var connectMulticaCmd = &cobra.Command{ RunE: runConnectMultica, } -var connectGitHubCmd = &cobra.Command{ - Use: "github --repo OWNER/REPO", - Short: "Connect GitHub as a harness connection", - RunE: runConnectGitHub, -} - var connectMnemonhubCmd = &cobra.Command{ Use: "mnemonhub --endpoint URL", Short: "Connect mnemonhub as a harness connection", @@ -48,10 +40,8 @@ func init() { connectMulticaCmd.Flags().StringVar(&connectMulticaWS, "workspace", "", "Multica workspace") connectMulticaCmd.Flags().StringVar(&connectMulticaRuntime, "runtime-binary", productconfig.DefaultMulticaRuntimeBinary, "Multica runtime binary") _ = connectMulticaCmd.Flags().MarkHidden("runtime-binary") - connectGitHubCmd.Flags().StringVar(&connectGitHubRepo, "repo", "", "GitHub repository owner/name") - connectGitHubCmd.Flags().StringVar(&connectGitHubBranch, "branch", "", "GitHub publication branch") connectMnemonhubCmd.Flags().StringVar(&connectMnemonhubURL, "endpoint", "", "mnemonhub endpoint") - connectCmd.AddCommand(connectMulticaCmd, connectGitHubCmd, connectMnemonhubCmd) + connectCmd.AddCommand(connectMulticaCmd, connectMnemonhubCmd) connectCmd.GroupID = groupSpine rootCmd.AddCommand(connectCmd) } @@ -83,30 +73,6 @@ func runConnectMultica(cmd *cobra.Command, args []string) error { return nil } -func runConnectGitHub(cmd *cobra.Command, args []string) error { - if strings.TrimSpace(connectGitHubRepo) == "" { - return fmt.Errorf("connect github requires --repo") - } - cfg, _, err := loadHarnessProductConfig(connectRoot, connectConfigPath) - if err != nil { - return err - } - cfg.Connections.GitHub = productconfig.GitHubConnection{ - Enabled: true, - Repo: strings.TrimSpace(connectGitHubRepo), - Branch: strings.TrimSpace(connectGitHubBranch), - } - cfg.Daemon.InteractionWatchers = appendUniqueString(cfg.Daemon.InteractionWatchers, productconfig.ConnectionGitHub) - cfg.Daemon.DisplaySurfaces = appendUniqueString(cfg.Daemon.DisplaySurfaces, productconfig.ConnectionGitHub) - path, err := saveHarnessProductConfig(connectRoot, connectConfigPath, cfg) - if err != nil { - return err - } - fmt.Fprintln(cmd.OutOrStdout(), "Connection: github ready") - fmt.Fprintf(cmd.OutOrStdout(), "Harness config: %s\n", path) - return nil -} - func runConnectMnemonhub(cmd *cobra.Command, args []string) error { if strings.TrimSpace(connectMnemonhubURL) == "" { return fmt.Errorf("connect mnemonhub requires --endpoint") diff --git a/harness/cmd/mnemon-harness/doctor.go b/harness/cmd/mnemon-harness/doctor.go index 00ec894e..efb65e28 100644 --- a/harness/cmd/mnemon-harness/doctor.go +++ b/harness/cmd/mnemon-harness/doctor.go @@ -66,9 +66,6 @@ func doctorConnections(cfg productconfig.Config) string { if cfg.Connections.Multica.Enabled { out = append(out, "multica") } - if cfg.Connections.GitHub.Enabled { - out = append(out, "github") - } if cfg.Connections.Mnemonhub.Enabled { out = append(out, "mnemonhub") } diff --git a/harness/cmd/mnemon-harness/doctor_test.go b/harness/cmd/mnemon-harness/doctor_test.go index 97002a63..d58975b0 100644 --- a/harness/cmd/mnemon-harness/doctor_test.go +++ b/harness/cmd/mnemon-harness/doctor_test.go @@ -36,7 +36,6 @@ func TestDoctorReportsConfiguredProductSurface(t *testing.T) { root := t.TempDir() cfg := productconfig.Default() cfg.Connections.Multica = productconfig.MulticaConnection{Enabled: true, Workspace: "ws-multica", RuntimeBinary: "mnemon-multica-runtime"} - cfg.Connections.GitHub = productconfig.GitHubConnection{Enabled: true, Repo: "mnemon-dev/mnemon-teamwork-example"} cfg.Participants = []productconfig.Participant{{ Principal: "planner@team", HostRuntime: productconfig.HostRuntime{ @@ -72,7 +71,7 @@ func TestDoctorReportsConfiguredProductSurface(t *testing.T) { "- Product config: configured", "- Participants: 1", "- Daemon roles: watchers=1 drive=1 surfaces=1", - "- Connections: multica,github", + "- Connections: multica", "- Local Mnemon config: missing", "- Daemon snapshot: workers=2", } { diff --git a/harness/cmd/mnemon-harness/hub.go b/harness/cmd/mnemon-harness/hub.go new file mode 100644 index 00000000..95efb4d8 --- /dev/null +++ b/harness/cmd/mnemon-harness/hub.go @@ -0,0 +1,863 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + "github.com/mnemon-dev/mnemon/harness/internal/productconfig" + "github.com/spf13/cobra" +) + +const defaultCloudflareEnvPath = "~/.mnemon/cloudflare-bootstrap.env" + +var ( + hubRoot string + hubConfigPath string + hubCloudflareEnvFile string + hubCloudflareWorkerName string + hubCloudflareSubdomain string + hubCloudflareAccountID string + hubCloudflarePrincipal string + hubCloudflareReplicaID string + hubCloudflareScope []string + hubCloudflareRemoteID string + hubCloudflareTimeout time.Duration + hubCloudflareNoDeploy bool +) + +var hubCmd = &cobra.Command{ + Use: "hub", + Short: "Manage harness MnemonHub connections", +} + +var hubBootstrapCmd = &cobra.Command{ + Use: "bootstrap", + Short: "Bootstrap a managed MnemonHub backend", +} + +var hubBootstrapCloudflareCmd = &cobra.Command{ + Use: "cloudflare", + Short: "Deploy and connect a Cloudflare Durable Object MnemonHub", + RunE: runHubBootstrapCloudflare, +} + +var hubDoctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Report harness MnemonHub connection readiness", + RunE: runHubDoctor, +} + +type commandRunner func(context.Context, commandInvocation) (commandResult, error) + +type httpDoer interface { + Do(*http.Request) (*http.Response, error) +} + +type commandInvocation struct { + Dir string + Env []string + Name string + Args []string + Stdin string + Redact []string +} + +type commandResult struct { + Stdout string + Stderr string +} + +type cloudflareBootstrapPlan struct { + Root string + ConfigPath string + EnvFile string + APIToken string + AccountID string + WorkerName string + Subdomain string + Principal string + ReplicaID string + ReplicaToken string + RemoteID string + Scopes []contract.ResourceRef + ProjectDir string + Endpoint string + Timeout time.Duration + NoDeploy bool + CommandRunner commandRunner + HTTPClient httpDoer + APIBaseURL string +} + +type cloudflareBootstrapResult struct { + Endpoint string + ConfigPath string + TokenRef string + Principal string + ReplicaID string + SmokePushOK bool + SmokePullOK bool + SmokeStatusOK bool + CloudflareTokenKeptLocal bool +} + +func init() { + hubCmd.PersistentFlags().StringVar(&hubRoot, "root", ".", "project root") + hubCmd.PersistentFlags().StringVar(&hubConfigPath, "config", "", "harness product config path") + _ = hubCmd.PersistentFlags().MarkHidden("config") + hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareEnvFile, "env-file", "", "Cloudflare bootstrap env file") + hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareWorkerName, "worker-name", "", "Cloudflare Worker name") + hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareSubdomain, "subdomain", "", "Cloudflare account workers.dev subdomain; empty reuses existing or creates a deterministic Mnemon subdomain") + hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareAccountID, "account-id", "", "Cloudflare account id") + hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflarePrincipal, "principal", "mnemon-replica@team", "local replica principal for MnemonHub sync") + hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareReplicaID, "replica-id", "", "local replica id; empty generates one") + hubBootstrapCloudflareCmd.Flags().StringArrayVar(&hubCloudflareScope, "scope", []string{"memory/project"}, "granted resource scope kind/id; repeatable") + hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareRemoteID, "remote", "cloudflare", "local Remote Workspace id") + hubBootstrapCloudflareCmd.Flags().DurationVar(&hubCloudflareTimeout, "timeout", 2*time.Minute, "bootstrap command timeout") + hubBootstrapCloudflareCmd.Flags().BoolVar(&hubCloudflareNoDeploy, "no-deploy", false, "prepare local config without running wrangler deploy or smoke") + hubBootstrapCmd.AddCommand(hubBootstrapCloudflareCmd) + hubCmd.AddCommand(hubBootstrapCmd, hubDoctorCmd) + hubCmd.GroupID = groupSpine + rootCmd.AddCommand(hubCmd) +} + +func runHubBootstrapCloudflare(cmd *cobra.Command, args []string) error { + plan, err := buildCloudflareBootstrapPlan(hubRoot, hubConfigPath) + if err != nil { + return err + } + result, err := bootstrapCloudflareHub(cmd.Context(), plan) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "MnemonHub connected") + fmt.Fprintf(cmd.OutOrStdout(), "Endpoint: %s\n", result.Endpoint) + fmt.Fprintf(cmd.OutOrStdout(), "Principal: %s\n", result.Principal) + fmt.Fprintf(cmd.OutOrStdout(), "Replica ID: %s\n", result.ReplicaID) + fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", result.ConfigPath) + fmt.Fprintf(cmd.OutOrStdout(), "Token file: %s\n", result.TokenRef) + if !plan.NoDeploy { + fmt.Fprintf(cmd.OutOrStdout(), "Smoke: push=%s pull=%s status=%s\n", okWord(result.SmokePushOK), okWord(result.SmokePullOK), okWord(result.SmokeStatusOK)) + } + fmt.Fprintln(cmd.OutOrStdout(), "Security: Cloudflare bootstrap token was not persisted by Mnemon.") + return nil +} + +func runHubDoctor(cmd *cobra.Command, args []string) error { + root := strings.TrimSpace(hubRoot) + if root == "" { + root = "." + } + cfg, status, detail := doctorProductConfig(root) + fmt.Fprintln(cmd.OutOrStdout(), "MnemonHub doctor") + fmt.Fprintf(cmd.OutOrStdout(), "- Product config: %s", status) + if detail != "" { + fmt.Fprintf(cmd.OutOrStdout(), " (%s)", detail) + } + fmt.Fprintln(cmd.OutOrStdout()) + if cfg.Connections.Mnemonhub.Enabled { + fmt.Fprintf(cmd.OutOrStdout(), "- Endpoint: %s\n", cfg.Connections.Mnemonhub.Endpoint) + } else { + fmt.Fprintln(cmd.OutOrStdout(), "- Endpoint: missing") + } + remotesPath := filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json") + if remote, err := exchange.LoadRemoteEntry(remotesPath, "default"); err == nil { + fmt.Fprintf(cmd.OutOrStdout(), "- Remote Workspace: %s backend=%s credential_ref=%s\n", remote.ID, remote.NormalizedBackend(), remote.CredentialRef) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "- Remote Workspace: missing (%v)\n", err) + } + fmt.Fprintln(cmd.OutOrStdout(), "- Free-plan estimate: 5 agents at 30s sync interval ~= 14,400 pulls/day before push/status overhead") + return nil +} + +func buildCloudflareBootstrapPlan(root, configPath string) (cloudflareBootstrapPlan, error) { + root = filepath.Clean(root) + env, envFile, err := loadCloudflareBootstrapEnv(hubCloudflareEnvFile) + if err != nil { + return cloudflareBootstrapPlan{}, err + } + apiToken := strings.TrimSpace(env["CLOUDFLARE_API_TOKEN"]) + if apiToken == "" && !hubCloudflareNoDeploy { + return cloudflareBootstrapPlan{}, fmt.Errorf("CLOUDFLARE_API_TOKEN is required") + } + accountID := firstNonEmpty(hubCloudflareAccountID, env["CLOUDFLARE_ACCOUNT_ID"]) + if strings.TrimSpace(accountID) == "" && !hubCloudflareNoDeploy { + return cloudflareBootstrapPlan{}, fmt.Errorf("CLOUDFLARE_ACCOUNT_ID is required") + } + workerName := firstNonEmpty(hubCloudflareWorkerName, env["MNEMON_CLOUDFLARE_WORKER_NAME"], "mnemon-r3-1-hub") + if err := validateCloudflareWorkerName(workerName); err != nil { + return cloudflareBootstrapPlan{}, err + } + subdomain := firstNonEmpty(hubCloudflareSubdomain, env["MNEMON_CLOUDFLARE_SUBDOMAIN"]) + if subdomain != "" { + if err := validateCloudflareWorkersSubdomain(subdomain); err != nil { + return cloudflareBootstrapPlan{}, err + } + } + replicaID := strings.TrimSpace(hubCloudflareReplicaID) + if replicaID == "" { + replicaID = "local-" + randomSuffix(6) + } + scopes, err := parseScopeRefs(hubCloudflareScope) + if err != nil { + return cloudflareBootstrapPlan{}, err + } + replicaToken, err := randomHex(32) + if err != nil { + return cloudflareBootstrapPlan{}, err + } + return cloudflareBootstrapPlan{ + Root: root, + ConfigPath: configPath, + EnvFile: envFile, + APIToken: apiToken, + AccountID: strings.TrimSpace(accountID), + WorkerName: workerName, + Subdomain: subdomain, + Principal: strings.TrimSpace(hubCloudflarePrincipal), + ReplicaID: replicaID, + ReplicaToken: replicaToken, + RemoteID: firstNonEmpty(hubCloudflareRemoteID, "cloudflare"), + Scopes: scopes, + ProjectDir: filepath.Join(repoRootForHarness(), "harness", "cloudflare", "mnemonhub"), + Timeout: hubCloudflareTimeout, + NoDeploy: hubCloudflareNoDeploy, + CommandRunner: defaultCommandRunner, + HTTPClient: http.DefaultClient, + APIBaseURL: "https://api.cloudflare.com/client/v4", + }, nil +} + +func bootstrapCloudflareHub(ctx context.Context, plan cloudflareBootstrapPlan) (cloudflareBootstrapResult, error) { + if strings.TrimSpace(plan.Principal) == "" { + return cloudflareBootstrapResult{}, fmt.Errorf("--principal is required") + } + if strings.TrimSpace(plan.ReplicaID) == "" { + return cloudflareBootstrapResult{}, fmt.Errorf("replica id is required") + } + if plan.CommandRunner == nil { + plan.CommandRunner = defaultCommandRunner + } + if plan.HTTPClient == nil { + plan.HTTPClient = http.DefaultClient + } + if strings.TrimSpace(plan.APIBaseURL) == "" { + plan.APIBaseURL = "https://api.cloudflare.com/client/v4" + } + endpoint := plan.Endpoint + if !plan.NoDeploy { + deployCtx := ctx + var cancel context.CancelFunc + if plan.Timeout > 0 { + deployCtx, cancel = context.WithTimeout(ctx, plan.Timeout) + defer cancel() + } + if _, err := ensureCloudflareWorkersSubdomain(deployCtx, plan); err != nil { + return cloudflareBootstrapResult{}, err + } + out, err := deployCloudflareWorker(deployCtx, plan) + if err != nil { + return cloudflareBootstrapResult{}, err + } + endpoint = cloudflareEndpointFromWranglerOutput(out.Stdout + "\n" + out.Stderr) + if endpoint == "" { + return cloudflareBootstrapResult{}, fmt.Errorf("wrangler deploy did not report a workers.dev endpoint") + } + if err := putCloudflareSecret(deployCtx, plan, "MNEMON_HUB_TOKENS_JSON", cloudflareTokensJSON(plan)); err != nil { + return cloudflareBootstrapResult{}, err + } + if err := putCloudflareSecret(deployCtx, plan, "MNEMON_HUB_GRANTS_JSON", cloudflareGrantsJSON(plan)); err != nil { + return cloudflareBootstrapResult{}, err + } + if err := smokeCloudflareHub(deployCtx, endpoint, plan); err != nil { + return cloudflareBootstrapResult{}, err + } + } else if endpoint == "" { + endpoint = fmt.Sprintf("https://%s.example.invalid", plan.WorkerName) + } + tokenRef, err := writeCloudflareLocalSyncConfig(plan, endpoint) + if err != nil { + return cloudflareBootstrapResult{}, err + } + cfgPath, err := writeCloudflareProductConfig(plan, endpoint) + if err != nil { + return cloudflareBootstrapResult{}, err + } + return cloudflareBootstrapResult{ + Endpoint: endpoint, + ConfigPath: cfgPath, + TokenRef: tokenRef, + Principal: plan.Principal, + ReplicaID: plan.ReplicaID, + SmokePushOK: !plan.NoDeploy, + SmokePullOK: !plan.NoDeploy, + SmokeStatusOK: !plan.NoDeploy, + CloudflareTokenKeptLocal: true, + }, nil +} + +func putCloudflareSecret(ctx context.Context, plan cloudflareBootstrapPlan, name, value string) error { + cmd := cloudflareWranglerCommand(ctx, plan, "secret", "put", name, "--name", plan.WorkerName) + cmd.Stdin = value + "\n" + _, err := plan.CommandRunner(ctx, cmd) + return err +} + +func deployCloudflareWorker(ctx context.Context, plan cloudflareBootstrapPlan) (commandResult, error) { + return plan.CommandRunner(ctx, cloudflareWranglerCommand(ctx, plan, "deploy", "--name", plan.WorkerName)) +} + +func ensureCloudflareWorkersSubdomain(ctx context.Context, plan cloudflareBootstrapPlan) (string, error) { + existing, err := getCloudflareWorkersSubdomain(ctx, plan) + if err != nil { + return "", err + } + if existing != "" { + return existing, nil + } + subdomain := strings.TrimSpace(plan.Subdomain) + if subdomain == "" { + subdomain = defaultCloudflareWorkersSubdomain(plan) + } + if err := validateCloudflareWorkersSubdomain(subdomain); err != nil { + return "", err + } + created, err := putCloudflareWorkersSubdomain(ctx, plan, subdomain) + if err != nil { + return "", err + } + return created, nil +} + +func getCloudflareWorkersSubdomain(ctx context.Context, plan cloudflareBootstrapPlan) (string, error) { + var resp struct { + Success bool `json:"success"` + Result struct { + Subdomain string `json:"subdomain"` + } `json:"result"` + Errors []cloudflareAPIError `json:"errors"` + } + status, err := doCloudflareAPI(ctx, plan, http.MethodGet, "/accounts/"+url.PathEscape(plan.AccountID)+"/workers/subdomain", nil, &resp) + if err != nil { + return "", err + } + if status == http.StatusNotFound { + return "", nil + } + if !resp.Success { + return "", fmt.Errorf("Cloudflare workers.dev subdomain lookup failed: %s", cloudflareErrorSummary(resp.Errors)) + } + return strings.TrimSpace(resp.Result.Subdomain), nil +} + +func putCloudflareWorkersSubdomain(ctx context.Context, plan cloudflareBootstrapPlan, subdomain string) (string, error) { + body, _ := json.Marshal(map[string]string{"subdomain": subdomain}) + var resp struct { + Success bool `json:"success"` + Result struct { + Subdomain string `json:"subdomain"` + } `json:"result"` + Errors []cloudflareAPIError `json:"errors"` + } + _, err := doCloudflareAPI(ctx, plan, http.MethodPut, "/accounts/"+url.PathEscape(plan.AccountID)+"/workers/subdomain", body, &resp) + if err != nil { + return "", err + } + if !resp.Success { + return "", fmt.Errorf("Cloudflare workers.dev subdomain create failed for %q: %s; choose another --subdomain or MNEMON_CLOUDFLARE_SUBDOMAIN", subdomain, cloudflareErrorSummary(resp.Errors)) + } + if strings.TrimSpace(resp.Result.Subdomain) == "" { + return "", fmt.Errorf("Cloudflare workers.dev subdomain create returned empty subdomain") + } + return strings.TrimSpace(resp.Result.Subdomain), nil +} + +type cloudflareAPIError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func doCloudflareAPI(ctx context.Context, plan cloudflareBootstrapPlan, method, path string, body []byte, out any) (int, error) { + base := strings.TrimRight(plan.APIBaseURL, "/") + req, err := http.NewRequestWithContext(ctx, method, base+path, bytes.NewReader(body)) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", "Bearer "+plan.APIToken) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := plan.HTTPClient.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return resp.StatusCode, err + } + if len(data) > 0 && out != nil { + if err := json.Unmarshal(data, out); err != nil { + return resp.StatusCode, fmt.Errorf("decode Cloudflare API response: %w", err) + } + } + if resp.StatusCode >= 500 { + return resp.StatusCode, fmt.Errorf("Cloudflare API %s %s returned HTTP %d", method, path, resp.StatusCode) + } + return resp.StatusCode, nil +} + +func cloudflareErrorSummary(errors []cloudflareAPIError) string { + if len(errors) == 0 { + return "unknown error" + } + parts := make([]string, 0, len(errors)) + for _, item := range errors { + if item.Code != 0 { + parts = append(parts, fmt.Sprintf("%d %s", item.Code, strings.TrimSpace(item.Message))) + continue + } + parts = append(parts, strings.TrimSpace(item.Message)) + } + return strings.Join(parts, "; ") +} + +func defaultCloudflareWorkersSubdomain(plan cloudflareBootstrapPlan) string { + account := strings.ToLower(strings.TrimSpace(plan.AccountID)) + if len(account) > 8 { + account = account[:8] + } + return "mnemon-" + sanitizeCloudflareLabel(account) +} + +func smokeCloudflareHub(ctx context.Context, endpoint string, plan cloudflareBootstrapPlan) error { + if err := waitCloudflareEndpointReady(ctx, endpoint); err != nil { + return err + } + client := access.NewClientWithToken(endpoint, plan.ReplicaToken) + smokeID := "cloudflare-smoke-" + randomSuffix(6) + ref := contract.ResourceRef{Kind: "memory", ID: "project"} + fields := map[string]any{"content": smokeID} + fieldsJSON, _ := json.Marshal(fields) + sum := sha256.Sum256(fieldsJSON) + env, err := contract.SyncedEventEnvelopeFromMaterial(contract.SyncedEventMaterial{ + OriginReplicaID: plan.ReplicaID, + LocalDecisionID: smokeID, + LocalIngestSeq: 1, + Actor: contract.ActorID(plan.Principal), + ResourceRef: ref, + ResourceVersion: 1, + FieldsDigest: hex.EncodeToString(sum[:]), + Fields: fields, + DecidedAt: time.Now().UTC().Format(time.RFC3339), + Status: "pending", + }) + if err != nil { + return err + } + var lastErr error + for i := 0; i < 10; i++ { + if ctx.Err() != nil { + return ctx.Err() + } + push, err := client.SyncPush(contract.SyncPushRequest{ReplicaID: plan.ReplicaID, BatchID: smokeID, Events: []eventmodel.EventEnvelope{env}}) + if err == nil && len(push.Accepted) == 1 { + if _, err = client.SyncPull(contract.SyncPullRequest{ReplicaID: plan.ReplicaID}); err != nil { + return err + } + if _, err = client.SyncStatus(); err != nil { + return err + } + return nil + } + if err != nil { + lastErr = err + } else { + lastErr = fmt.Errorf("smoke push %s", syncPushDiagnostic(push)) + } + time.Sleep(time.Duration(i+1) * time.Second) + } + return fmt.Errorf("cloudflare smoke failed: %w", lastErr) +} + +func syncPushDiagnostic(push contract.SyncPushResponse) string { + parts := []string{fmt.Sprintf("accepted=%d rejected=%d conflicts=%d", len(push.Accepted), len(push.Rejected), len(push.Conflicts))} + for _, item := range push.Rejected { + parts = append(parts, fmt.Sprintf("rejected event=%s subject=%s diagnostic=%q", item.EventID, item.Subject, item.Diagnostic)) + } + for _, item := range push.Conflicts { + parts = append(parts, fmt.Sprintf("conflict event=%s subject=%s diagnostic=%q", item.EventID, item.Subject, item.Diagnostic)) + } + return strings.Join(parts, "; ") +} + +func waitCloudflareEndpointReady(ctx context.Context, endpoint string) error { + client := &http.Client{Timeout: 20 * time.Second} + var lastErr error + for i := 0; i < 10; i++ { + if ctx.Err() != nil { + return ctx.Err() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(endpoint, "/")+"/", nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err == nil { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + _ = resp.Body.Close() + if resp.StatusCode < 500 { + return nil + } + lastErr = fmt.Errorf("endpoint returned HTTP %d", resp.StatusCode) + } else { + lastErr = err + } + time.Sleep(time.Duration(i+1) * time.Second) + } + return fmt.Errorf("cloudflare endpoint readiness failed: %w", lastErr) +} + +func writeCloudflareLocalSyncConfig(plan cloudflareBootstrapPlan, endpoint string) (string, error) { + remoteID := strings.TrimSpace(plan.RemoteID) + if remoteID == "" { + remoteID = "cloudflare" + } + if err := upsertSyncRemote(filepath.Join(plan.Root, ".mnemon", "harness", "sync", "remotes.json"), plan.Root, remoteID, exchange.RemoteBackendHTTP, "", endpoint, plan.ReplicaToken, "", ""); err != nil { + return "", err + } + return filepath.ToSlash(filepath.Join(".mnemon", "harness", "sync", "credentials", remoteID+".token")), nil +} + +func writeCloudflareProductConfig(plan cloudflareBootstrapPlan, endpoint string) (string, error) { + cfg, _, err := loadHarnessProductConfig(plan.Root, plan.ConfigPath) + if err != nil { + return "", err + } + cfg.Connections.Mnemonhub = productconfig.MnemonhubConnection{Enabled: true, Endpoint: endpoint} + cfg.Daemon.InteractionWatchers = appendUniqueString(cfg.Daemon.InteractionWatchers, productconfig.ConnectionMnemonhub) + return saveHarnessProductConfig(plan.Root, plan.ConfigPath, cfg) +} + +func cloudflareWranglerCommand(ctx context.Context, plan cloudflareBootstrapPlan, args ...string) commandInvocation { + name, baseArgs := resolveWranglerCommand() + env := append(os.Environ(), + "CLOUDFLARE_API_TOKEN="+plan.APIToken, + "CLOUDFLARE_ACCOUNT_ID="+plan.AccountID, + ) + allArgs := append(baseArgs, args...) + return commandInvocation{ + Dir: plan.ProjectDir, + Env: env, + Name: name, + Args: allArgs, + Redact: []string{plan.APIToken, plan.ReplicaToken}, + } +} + +func resolveWranglerCommand() (string, []string) { + if path, err := exec.LookPath("wrangler"); err == nil { + return path, nil + } + if runtime.GOOS == "windows" { + return "npx.cmd", []string{"--yes", "wrangler"} + } + return "npx", []string{"--yes", "wrangler"} +} + +func defaultCommandRunner(ctx context.Context, inv commandInvocation) (commandResult, error) { + cmd := exec.CommandContext(ctx, inv.Name, inv.Args...) + cmd.Dir = inv.Dir + cmd.Env = inv.Env + if inv.Stdin != "" { + cmd.Stdin = strings.NewReader(inv.Stdin) + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + result := commandResult{Stdout: redact(stdout.String(), inv.Redact), Stderr: redact(stderr.String(), inv.Redact)} + if err != nil { + return result, fmt.Errorf("%s %s failed: %w\n%s", filepath.Base(inv.Name), strings.Join(inv.Args, " "), err, result.Stderr) + } + return result, nil +} + +func cloudflareTokensJSON(plan cloudflareBootstrapPlan) string { + raw, _ := json.Marshal(map[string]string{plan.ReplicaToken: plan.Principal}) + return string(raw) +} + +func cloudflareGrantsJSON(plan cloudflareBootstrapPlan) string { + raw, _ := json.Marshal(map[string]map[string]any{ + plan.Principal: { + "principal": plan.Principal, + "scopes": plan.Scopes, + }, + }) + return string(raw) +} + +func cloudflareEndpointFromWranglerOutput(text string) string { + re := regexp.MustCompile(`https://[A-Za-z0-9][A-Za-z0-9.-]*\.workers\.dev`) + matches := re.FindAllString(text, -1) + if len(matches) == 0 { + return "" + } + return matches[len(matches)-1] +} + +func loadCloudflareBootstrapEnv(explicit string) (map[string]string, string, error) { + out := map[string]string{} + for _, key := range []string{"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "MNEMON_CLOUDFLARE_WORKER_NAME", "MNEMON_CLOUDFLARE_SUBDOMAIN"} { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + out[key] = value + } + } + for _, path := range []string{explicit, expandHome(defaultCloudflareEnvPath)} { + path = strings.TrimSpace(path) + if path == "" { + continue + } + values, ok, err := readCloudflareEnvFile(path) + if err != nil { + return nil, "", err + } + if !ok { + continue + } + for key, value := range values { + if strings.TrimSpace(out[key]) == "" { + out[key] = value + } + } + return out, path, nil + } + return out, "", nil +} + +func readCloudflareEnvFile(path string) (map[string]string, bool, error) { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + if info.Mode().Perm()&0o077 != 0 { + return nil, false, fmt.Errorf("Cloudflare env file %s must be permission 0600 or stricter", path) + } + f, err := os.Open(path) + if err != nil { + return nil, false, err + } + defer f.Close() + allowed := map[string]bool{"CLOUDFLARE_API_TOKEN": true, "CLOUDFLARE_ACCOUNT_ID": true, "MNEMON_CLOUDFLARE_WORKER_NAME": true, "MNEMON_CLOUDFLARE_SUBDOMAIN": true} + values := map[string]string{} + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + return nil, false, fmt.Errorf("invalid env line in %s", path) + } + key = strings.TrimSpace(key) + if !allowed[key] { + return nil, false, fmt.Errorf("unsupported Cloudflare env key %q in %s", key, path) + } + values[key] = strings.Trim(strings.TrimSpace(value), `"'`) + } + return values, true, scanner.Err() +} + +func parseScopeRefs(values []string) ([]contract.ResourceRef, error) { + if len(values) == 0 { + return nil, fmt.Errorf("at least one --scope kind/id is required") + } + seen := map[string]bool{} + var out []contract.ResourceRef + for _, value := range values { + kind, id, ok := strings.Cut(strings.TrimSpace(value), "/") + if !ok || strings.TrimSpace(kind) == "" || strings.TrimSpace(id) == "" { + return nil, fmt.Errorf("scope %q must be kind/id", value) + } + ref := contract.ResourceRef{Kind: contract.ResourceKind(strings.TrimSpace(kind)), ID: contract.ResourceID(strings.TrimSpace(id))} + key := string(ref.Kind) + "/" + string(ref.ID) + if !seen[key] { + out = append(out, ref) + seen[key] = true + } + } + sort.Slice(out, func(i, j int) bool { + return string(out[i].Kind)+"/"+string(out[i].ID) < string(out[j].Kind)+"/"+string(out[j].ID) + }) + return out, nil +} + +func validateCloudflareWorkerName(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("Cloudflare worker name is required") + } + if len(name) > 63 { + return fmt.Errorf("Cloudflare worker name must be at most 63 characters") + } + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + continue + } + return fmt.Errorf("Cloudflare worker name %q must use lowercase letters, numbers, or dash", name) + } + return nil +} + +func validateCloudflareWorkersSubdomain(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("Cloudflare workers.dev subdomain is required") + } + if len(name) > 63 { + return fmt.Errorf("Cloudflare workers.dev subdomain must be at most 63 characters") + } + if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") { + return fmt.Errorf("Cloudflare workers.dev subdomain must not start or end with dash") + } + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + continue + } + return fmt.Errorf("Cloudflare workers.dev subdomain %q must use lowercase letters, numbers, or dash", name) + } + return nil +} + +func sanitizeCloudflareLabel(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var b strings.Builder + lastDash := false + for _, r := range value { + ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') + if ok { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + out = "hub" + } + if len(out) > 56 { + out = strings.Trim(out[:56], "-") + } + if out == "" { + out = "hub" + } + return out +} + +func randomHex(bytesLen int) (string, error) { + buf := make([]byte, bytesLen) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func randomSuffix(bytesLen int) string { + value, err := randomHex(bytesLen) + if err != nil { + return "unknown" + } + return value +} + +func redact(text string, secrets []string) string { + for _, secret := range secrets { + if strings.TrimSpace(secret) != "" { + text = strings.ReplaceAll(text, secret, "") + } + } + return text +} + +func expandHome(path string) string { + if path == "~" { + if home, err := os.UserHomeDir(); err == nil { + return home + } + } + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, path[2:]) + } + } + return path +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func repoRootForHarness() string { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "." + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..")) +} + +func okWord(ok bool) string { + if ok { + return "ok" + } + return "skipped" +} + +func validateEndpoint(endpoint string) error { + u, err := url.Parse(endpoint) + if err != nil { + return err + } + if u.Scheme != "https" && u.Scheme != "http" { + return fmt.Errorf("endpoint must be http or https") + } + if u.Host == "" { + return fmt.Errorf("endpoint host is required") + } + return nil +} diff --git a/harness/cmd/mnemon-harness/hub_test.go b/harness/cmd/mnemon-harness/hub_test.go new file mode 100644 index 00000000..283b3ef7 --- /dev/null +++ b/harness/cmd/mnemon-harness/hub_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" +) + +func TestCloudflareEnvFileMergesWithoutOverridingProcessEnv(t *testing.T) { + t.Setenv("CLOUDFLARE_API_TOKEN", "process-token") + path := filepath.Join(t.TempDir(), "cloudflare.env") + if err := os.WriteFile(path, []byte("CLOUDFLARE_API_TOKEN=file-token\nCLOUDFLARE_ACCOUNT_ID=account-1\nMNEMON_CLOUDFLARE_WORKER_NAME=mnemon-r3\nMNEMON_CLOUDFLARE_SUBDOMAIN=mnemon-test\n"), 0o600); err != nil { + t.Fatal(err) + } + env, used, err := loadCloudflareBootstrapEnv(path) + if err != nil { + t.Fatal(err) + } + if used != path { + t.Fatalf("env file = %q, want %q", used, path) + } + if env["CLOUDFLARE_API_TOKEN"] != "process-token" { + t.Fatalf("process env must win, got %q", env["CLOUDFLARE_API_TOKEN"]) + } + if env["CLOUDFLARE_ACCOUNT_ID"] != "account-1" || env["MNEMON_CLOUDFLARE_WORKER_NAME"] != "mnemon-r3" || env["MNEMON_CLOUDFLARE_SUBDOMAIN"] != "mnemon-test" { + t.Fatalf("file values not loaded: %+v", env) + } +} + +func TestCloudflareEnvFileRejectsLoosePermissions(t *testing.T) { + path := filepath.Join(t.TempDir(), "cloudflare.env") + if err := os.WriteFile(path, []byte("CLOUDFLARE_API_TOKEN=file-token\n"), 0o644); err != nil { + t.Fatal(err) + } + _, _, err := readCloudflareEnvFile(path) + if err == nil || !strings.Contains(err.Error(), "0600") { + t.Fatalf("expected permission error, got %v", err) + } +} + +func TestCloudflareEndpointFromWranglerOutput(t *testing.T) { + out := "Uploaded mnemon\nPublished mnemon-r3 (1.2 sec)\n https://mnemon-r3.example.workers.dev\n" + if got := cloudflareEndpointFromWranglerOutput(out); got != "https://mnemon-r3.example.workers.dev" { + t.Fatalf("endpoint = %q", got) + } +} + +func TestEnsureCloudflareWorkersSubdomainCreatesWhenMissing(t *testing.T) { + var requests []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer cf-token" { + t.Fatalf("authorization header = %q", got) + } + requests = append(requests, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + _, _ = w.Write([]byte(`{"success":true,"result":{"subdomain":""},"errors":[]}`)) + case http.MethodPut: + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["subdomain"] != "mnemon-0ff22127" { + t.Fatalf("subdomain body = %+v", body) + } + _, _ = w.Write([]byte(`{"success":true,"result":{"subdomain":"mnemon-0ff22127"},"errors":[]}`)) + default: + http.Error(w, "unexpected method", http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + got, err := ensureCloudflareWorkersSubdomain(context.Background(), cloudflareBootstrapPlan{ + APIToken: "cf-token", + AccountID: "0ff22127f3f11976dfea078f13f4c056", + HTTPClient: server.Client(), + APIBaseURL: server.URL, + }) + if err != nil { + t.Fatal(err) + } + if got != "mnemon-0ff22127" { + t.Fatalf("subdomain = %q", got) + } + want := []string{ + "GET /accounts/0ff22127f3f11976dfea078f13f4c056/workers/subdomain", + "PUT /accounts/0ff22127f3f11976dfea078f13f4c056/workers/subdomain", + } + if strings.Join(requests, "\n") != strings.Join(want, "\n") { + t.Fatalf("requests = %#v, want %#v", requests, want) + } +} + +func TestEnsureCloudflareWorkersSubdomainReusesExisting(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("existing subdomain should not be overwritten, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"result":{"subdomain":"existing-subdomain"},"errors":[]}`)) + })) + defer server.Close() + + got, err := ensureCloudflareWorkersSubdomain(context.Background(), cloudflareBootstrapPlan{ + APIToken: "cf-token", + AccountID: "acct-1", + Subdomain: "ignored-when-existing", + HTTPClient: server.Client(), + APIBaseURL: server.URL, + }) + if err != nil { + t.Fatal(err) + } + if got != "existing-subdomain" { + t.Fatalf("subdomain = %q", got) + } +} + +func TestCloudflareGrantAndTokenJSONDoNotExposeCloudflareToken(t *testing.T) { + plan := cloudflareBootstrapPlan{ + APIToken: "cfat_secret", + ReplicaToken: "replica-secret", + Principal: "planner@team", + Scopes: mustParseScopesForTest(t, []string{"memory/project"}), + } + tokens := cloudflareTokensJSON(plan) + if strings.Contains(tokens, plan.APIToken) { + t.Fatalf("tokens JSON leaked Cloudflare token: %s", tokens) + } + grants := cloudflareGrantsJSON(plan) + if strings.Contains(grants, plan.APIToken) || strings.Contains(grants, plan.ReplicaToken) { + t.Fatalf("grants JSON leaked a secret: %s", grants) + } + var decoded map[string]map[string]any + if err := json.Unmarshal([]byte(grants), &decoded); err != nil { + t.Fatal(err) + } + if decoded["planner@team"]["principal"] != "planner@team" { + t.Fatalf("grant principal mismatch: %+v", decoded) + } + scopes, ok := decoded["planner@team"]["scopes"].([]any) + if !ok || len(scopes) != 1 { + t.Fatalf("grant scopes missing: %+v", decoded["planner@team"]["scopes"]) + } + scope, ok := scopes[0].(map[string]any) + if !ok || scope["kind"] != "memory" || scope["id"] != "project" { + t.Fatalf("grant scopes must use lower-case ABI keys, got %+v", scopes[0]) + } + if _, hasUpperKind := scope["Kind"]; hasUpperKind { + t.Fatalf("grant scopes leaked Go field name Kind: %+v", scope) + } +} + +func TestBootstrapCloudflareNoDeployWritesLocalConfigWithoutRunningCommands(t *testing.T) { + root := t.TempDir() + plan := cloudflareBootstrapPlan{ + Root: root, + WorkerName: "mnemon-r3", + Principal: "planner@team", + ReplicaID: "local-a", + ReplicaToken: "replica-secret", + RemoteID: "cloudflare", + Scopes: mustParseScopesForTest(t, []string{"memory/project"}), + Endpoint: "https://mnemon-r3.example.workers.dev", + NoDeploy: true, + CommandRunner: func(context.Context, commandInvocation) (commandResult, error) { + t.Fatal("no-deploy must not run external commands") + return commandResult{}, nil + }, + } + result, err := bootstrapCloudflareHub(context.Background(), plan) + if err != nil { + t.Fatal(err) + } + if result.Endpoint != plan.Endpoint || result.ConfigPath == "" || result.TokenRef == "" { + t.Fatalf("result mismatch: %+v", result) + } + tokenPath := filepath.Join(root, filepath.FromSlash(result.TokenRef)) + if raw, err := os.ReadFile(tokenPath); err != nil || strings.TrimSpace(string(raw)) != "replica-secret" { + t.Fatalf("token file mismatch raw=%q err=%v", string(raw), err) + } + cfg, err := os.ReadFile(filepath.Join(root, ".mnemon", "harness", "config.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(cfg), "replica-secret") || strings.Contains(string(cfg), "cfat_") { + t.Fatalf("product config must not contain secrets:\n%s", string(cfg)) + } +} + +func mustParseScopesForTest(t *testing.T, values []string) []contract.ResourceRef { + t.Helper() + scopes, err := parseScopeRefs(values) + if err != nil { + t.Fatal(err) + } + return scopes +} diff --git a/harness/cmd/mnemon-harness/root_test.go b/harness/cmd/mnemon-harness/root_test.go index cab7aa44..d80ad0fe 100644 --- a/harness/cmd/mnemon-harness/root_test.go +++ b/harness/cmd/mnemon-harness/root_test.go @@ -23,7 +23,7 @@ func TestRootHelpUsesLocalFirstProductSurface(t *testing.T) { t.Fatalf("root help returned error: %v", err) } got := out.String() - for _, want := range []string{"event-driven", "collaboration substrate", "Teamwork is a profile", "Agent Integration", "Local Mnemon", "setup", "config", "daemon", "doctor", "status", "session", "agent", "connect"} { + for _, want := range []string{"event-driven", "collaboration substrate", "Teamwork is a profile", "Agent Integration", "Local Mnemon", "setup", "config", "daemon", "doctor", "status", "session", "agent", "connect", "hub"} { if !strings.Contains(got, want) { t.Fatalf("expected root help to contain %q:\n%s", want, got) } @@ -42,7 +42,7 @@ func TestRootHelpUsesLocalFirstProductSurface(t *testing.T) { func TestPublicRootCommandsMatchProductSurface(t *testing.T) { got := publicRootCommandNames() - want := []string{"agent", "config", "connect", "daemon", "doctor", "session", "setup", "status"} + want := []string{"agent", "config", "connect", "daemon", "doctor", "hub", "session", "setup", "status"} sort.Strings(want) if strings.Join(got, "\x00") != strings.Join(want, "\x00") { t.Fatalf("public root commands mismatch:\ngot: %v\nwant: %v", got, want) diff --git a/harness/cmd/mnemon-harness/sync.go b/harness/cmd/mnemon-harness/sync.go index 10084439..09045032 100644 --- a/harness/cmd/mnemon-harness/sync.go +++ b/harness/cmd/mnemon-harness/sync.go @@ -12,7 +12,6 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/contract" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" - githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" "github.com/mnemon-dev/mnemon/harness/internal/runtime" "github.com/spf13/cobra" ) @@ -28,8 +27,6 @@ var ( syncRemoteToken string syncRemoteTokenFile string syncCAFile string - syncGitHubRepo string - syncGitHubBranch string syncAllowInsecure bool syncOnce bool syncBackground bool @@ -72,14 +69,12 @@ func init() { syncCmd.PersistentFlags().StringVar(&syncStorePath, "store", "", "Local Mnemon store path") syncCmd.PersistentFlags().StringVar(&syncRemotesPath, "remotes", "", "Remote Workspace config path") syncCmd.PersistentFlags().StringVar(&syncRemoteID, "remote", "default", "Remote Workspace id") - syncCmd.PersistentFlags().StringVar(&syncRemoteBackend, "backend", "", "Remote Workspace backend (http or github)") + syncCmd.PersistentFlags().StringVar(&syncRemoteBackend, "backend", "", "Remote Workspace backend (http)") syncCmd.PersistentFlags().StringVar(&syncRemoteDirection, "direction", "", "Remote Workspace direction (bidirectional, publish, or subscribe)") syncCmd.PersistentFlags().StringVar(&syncRemoteURL, "remote-url", "", "Remote Workspace sync endpoint") syncCmd.PersistentFlags().StringVar(&syncRemoteToken, "token", "", "Remote Workspace sync token") syncCmd.PersistentFlags().StringVar(&syncRemoteTokenFile, "token-file", "", "Remote Workspace sync token file") syncCmd.PersistentFlags().StringVar(&syncCAFile, "ca-file", "", "PEM bundle pinning the Remote Workspace TLS root (e.g. the mnemon-hub --dev-selfsigned cert)") - syncCmd.PersistentFlags().StringVar(&syncGitHubRepo, "github-repo", "", "GitHub Remote Workspace repository (owner/name)") - syncCmd.PersistentFlags().StringVar(&syncGitHubBranch, "github-branch", "", "GitHub Remote Workspace publication branch") syncCmd.PersistentFlags().BoolVar(&syncAllowInsecure, "allow-insecure-remote", false, "explicitly allow a plaintext http:// Remote Workspace endpoint with a non-loopback host (T2: fail-closed by default)") _ = syncCmd.PersistentFlags().MarkHidden("store") _ = syncCmd.PersistentFlags().MarkHidden("remotes") @@ -110,7 +105,6 @@ func runSyncConnect(cmd *cobra.Command, args []string) error { if err != nil { return err } - repo, branch := "", "" switch backend { case exchange.RemoteBackendHTTP: if endpoint == "" { @@ -122,15 +116,6 @@ func runSyncConnect(cmd *cobra.Command, args []string) error { if err := access.ValidateSyncEndpoint(endpoint, syncAllowInsecure); err != nil { return err } - case exchange.RemoteBackendGitHub: - repo, err = exchange.NormalizeGitHubRepo(syncGitHubRepo) - if err != nil { - return err - } - branch, err = exchange.NormalizePublicationBranch(syncGitHubBranch) - if err != nil { - return err - } default: return fmt.Errorf("unsupported Remote Workspace backend %q", backend) } @@ -141,7 +126,7 @@ func runSyncConnect(cmd *cobra.Command, args []string) error { if backend == exchange.RemoteBackendHTTP && direction == exchange.RemoteDirectionBidirectional { directionForWrite = "" } - if err := upsertSyncRemote(resolvedSyncRemotesPath(), syncProjectRoot(), workspace, backend, directionForWrite, endpoint, repo, branch, syncRemoteToken, syncRemoteTokenFile, syncCAFile); err != nil { + if err := upsertSyncRemote(resolvedSyncRemotesPath(), syncProjectRoot(), workspace, backend, directionForWrite, endpoint, syncRemoteToken, syncRemoteTokenFile, syncCAFile); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "Remote Workspace: connected %s\n", workspace) @@ -307,8 +292,6 @@ type syncRemoteConfig struct { ID string Backend string Endpoint string - Repo string - Branch string Token string CAFile string } @@ -333,19 +316,6 @@ func syncRemoteWorkspaceFor(remote syncRemoteConfig) (exchange.RemoteWorkspace, CAFile: remote.CAFile, AllowInsecure: syncAllowInsecure, }) - case exchange.RemoteBackendGitHub: - store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ - Repo: remote.Repo, - Token: remote.Token, - }) - if err != nil { - return nil, err - } - return githubbackend.New(githubbackend.Config{ - Store: store, - Repo: remote.Repo, - Branch: remote.Branch, - }) default: return nil, fmt.Errorf("Remote Workspace %q: unsupported backend %q", remote.ID, backend) } @@ -414,7 +384,7 @@ func resolveSyncRemoteEntry(entry exchange.RemoteEntry) (syncRemoteConfig, error if err != nil { return syncRemoteConfig{}, err } - return syncRemoteConfig{ID: entry.ID, Backend: entry.NormalizedBackend(), Endpoint: entry.Endpoint, Repo: entry.Repo, Branch: entry.Branch, Token: token, CAFile: resolvedSyncCAFile(entry.CAFile)}, nil + return syncRemoteConfig{ID: entry.ID, Backend: entry.NormalizedBackend(), Endpoint: entry.Endpoint, Token: token, CAFile: resolvedSyncCAFile(entry.CAFile)}, nil } // resolvedSyncCAFile picks the pinned-root file: the --ca-file flag overrides the remotes.json @@ -430,7 +400,7 @@ func resolvedSyncCAFile(entryCAFile string) string { return resolveSyncPath(caFile) } -func upsertSyncRemote(path, root, id, backend, direction, endpoint, repo, branch, token, tokenFile, caFile string) error { +func upsertSyncRemote(path, root, id, backend, direction, endpoint, token, tokenFile, caFile string) error { doc := exchange.RemotesDoc{SchemaVersion: 1} if raw, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(raw))) > 0 { if err := json.Unmarshal(raw, &doc); err != nil { @@ -446,7 +416,7 @@ func upsertSyncRemote(path, root, id, backend, direction, endpoint, repo, branch if err != nil { return err } - entry := exchange.RemoteEntry{Backend: backend, Direction: direction, ID: id, Endpoint: endpoint, Repo: repo, Branch: branch, CredentialRef: credentialRef, CAFile: normalizeSyncFileRef(caFile)} + entry := exchange.RemoteEntry{Backend: backend, Direction: direction, ID: id, Endpoint: endpoint, CredentialRef: credentialRef, CAFile: normalizeSyncFileRef(caFile)} replaced := false for i := range doc.Remotes { if doc.Remotes[i].ID == id { diff --git a/harness/cmd/mnemon-harness/sync_test.go b/harness/cmd/mnemon-harness/sync_test.go index c3290193..a2ee26ba 100644 --- a/harness/cmd/mnemon-harness/sync_test.go +++ b/harness/cmd/mnemon-harness/sync_test.go @@ -434,53 +434,24 @@ func TestSyncConnectWritesRemoteConfigWithoutLeakingToken(t *testing.T) { } } -func TestSyncConnectWritesGitHubRemoteConfigWithoutLeakingToken(t *testing.T) { +func TestSyncConnectRejectsGitHubBackend(t *testing.T) { restoreSyncFlags(t) root := t.TempDir() syncRoot = root - syncRemoteBackend = exchange.RemoteBackendGitHub - syncRemoteDirection = exchange.RemoteDirectionPublish - syncGitHubRepo = "mnemon-dev/mnemon-teamwork-example" - syncGitHubBranch = "mnemon/mnemond-a" - syncRemoteToken = "secret-github-token" + syncRemoteBackend = "github" + syncRemoteToken = "secret-remote-token" var out bytes.Buffer cmd := mustTestCommand(t) cmd.SetOut(&out) - if err := runSyncConnect(cmd, []string{"self"}); err != nil { - t.Fatalf("sync connect github: %v", err) + err := runSyncConnect(cmd, []string{"self"}) + if err == nil || !strings.Contains(err.Error(), "unsupported Remote Workspace backend") { + t.Fatalf("sync connect github backend must fail closed, got %v", err) } - if strings.Contains(out.String(), "secret-github-token") { + if strings.Contains(out.String(), "secret-remote-token") { t.Fatalf("sync connect output must not expose token:\n%s", out.String()) } - config := string(mustReadCmd(t, filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json"))) - for _, want := range []string{ - `"backend": "github"`, - `"direction": "publish"`, - `"id": "self"`, - `"repo": "mnemon-dev/mnemon-teamwork-example"`, - `"branch": "mnemon/mnemond-a"`, - `"credential_ref": ".mnemon/harness/sync/credentials/self.token"`, - } { - if !strings.Contains(config, want) { - t.Fatalf("sync connect github config missing %q:\n%s", want, config) - } - } - if strings.Contains(config, "secret-github-token") || strings.Contains(config, "endpoint") { - t.Fatalf("github remote config must not leak token or write an endpoint:\n%s", config) - } - syncRemoteBackend = "" - syncRemoteDirection = "" - syncGitHubRepo = "" - syncGitHubBranch = "" - syncRemoteToken = "" - remote, err := resolveSyncRemote() - if err != nil { - t.Fatalf("resolve github remote: %v", err) - } - if remote.ID != "self" || remote.Backend != exchange.RemoteBackendGitHub || - remote.Repo != "mnemon-dev/mnemon-teamwork-example" || remote.Branch != "mnemon/mnemond-a" || - remote.Token != "secret-github-token" { - t.Fatalf("github remote not resolved: %+v", remote) + if _, err := os.Stat(filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json")); !os.IsNotExist(err) { + t.Fatalf("unsupported backend must not write remotes.json, err=%v", err) } } @@ -580,8 +551,6 @@ func restoreSyncFlags(t *testing.T) { oldRemoteToken := syncRemoteToken oldRemoteTokenFile := syncRemoteTokenFile oldCAFile := syncCAFile - oldGitHubRepo := syncGitHubRepo - oldGitHubBranch := syncGitHubBranch oldAllowInsecure := syncAllowInsecure t.Cleanup(func() { syncRoot = oldRoot @@ -594,8 +563,6 @@ func restoreSyncFlags(t *testing.T) { syncRemoteToken = oldRemoteToken syncRemoteTokenFile = oldRemoteTokenFile syncCAFile = oldCAFile - syncGitHubRepo = oldGitHubRepo - syncGitHubBranch = oldGitHubBranch syncAllowInsecure = oldAllowInsecure }) syncRoot = "." @@ -608,8 +575,6 @@ func restoreSyncFlags(t *testing.T) { syncRemoteToken = "" syncRemoteTokenFile = "" syncCAFile = "" - syncGitHubRepo = "" - syncGitHubBranch = "" syncAllowInsecure = false } diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go index e47de007..f03752ef 100644 --- a/harness/cmd/mnemon-multica-runtime/main.go +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -73,17 +73,6 @@ type runtimeImportResult struct { Err error } -type providerTurnResult struct { - Configured bool - Runtime string - Command string - CWD string - Output string - ExitCode int - DurationMs int64 - Err error -} - func main() { if err := runRuntime(runtimeConfig{ Args: os.Args[1:], @@ -138,17 +127,47 @@ func runRuntime(cfg runtimeConfig) error { } func runRuntimeRPC(cfg runtimeConfig, cwd string) error { - scanner := bufio.NewScanner(cfg.Stdin) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - state := runtimeRPCState{CWD: cwd, Env: cfg.Env, Now: cfg.Now} - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { + reader := bufio.NewReaderSize(cfg.Stdin, 1024*1024) + if runtimeHasIssueActivation(cfg.Env, cwd, multicasurface.RuntimeInput{}) { + return runManagedRuntimeRPC(cfg, cwd, nil, reader) + } + + var buffered []string + for { + raw, err := reader.ReadString('\n') + if raw != "" { + buffered = append(buffered, raw) + msg, ok := decodeRuntimeRPCLine(raw, cfg.Stderr) + if ok && msg.Method == "turn/start" { + input := multicasurface.RuntimeInputMaterial(msg.Params) + if runtimeHasIssueActivation(cfg.Env, cwd, input) { + return runManagedRuntimeRPC(cfg, cwd, buffered, reader) + } + proxyCfg := cfg + proxyCfg.Stdin = io.MultiReader(strings.NewReader(strings.Join(buffered, "")), reader) + return runProviderStdioProxy(proxyCfg, cwd) + } + } + if err == nil { continue } - var msg rpcMessage - if err := json.Unmarshal([]byte(line), &msg); err != nil { - fmt.Fprintf(cfg.Stderr, "%s: ignoring invalid rpc line: %v\n", multicasurface.MulticaRuntimeCommandName, err) + if err == io.EOF { + if len(buffered) == 0 { + return nil + } + proxyCfg := cfg + proxyCfg.Stdin = strings.NewReader(strings.Join(buffered, "")) + return runProviderStdioProxy(proxyCfg, cwd) + } + return err + } +} + +func runManagedRuntimeRPC(cfg runtimeConfig, cwd string, prelude []string, reader *bufio.Reader) error { + state := runtimeRPCState{CWD: cwd, Env: cfg.Env, Now: cfg.Now} + for _, raw := range prelude { + msg, ok := decodeRuntimeRPCLine(raw, cfg.Stderr) + if !ok { continue } if err := state.handle(msg, func(response rpcMessage) error { @@ -157,7 +176,39 @@ func runRuntimeRPC(cfg runtimeConfig, cwd string) error { return err } } - return scanner.Err() + for { + raw, err := reader.ReadString('\n') + if raw != "" { + msg, ok := decodeRuntimeRPCLine(raw, cfg.Stderr) + if ok { + if handleErr := state.handle(msg, func(response rpcMessage) error { + return writeRuntimeRPC(cfg.Stdout, response) + }); handleErr != nil { + return handleErr + } + } + } + if err == nil { + continue + } + if err == io.EOF { + return nil + } + return err + } +} + +func decodeRuntimeRPCLine(raw string, stderr io.Writer) (rpcMessage, bool) { + line := strings.TrimSpace(raw) + if line == "" { + return rpcMessage{}, false + } + var msg rpcMessage + if err := json.Unmarshal([]byte(line), &msg); err != nil { + fmt.Fprintf(stderr, "%s: ignoring invalid rpc line: %v\n", multicasurface.MulticaRuntimeCommandName, err) + return rpcMessage{}, false + } + return msg, true } func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) error { @@ -292,16 +343,76 @@ func (s *runtimeRPCState) nextItemID(prefix string) string { func (s *runtimeRPCState) runTurn(input multicasurface.RuntimeInput, progress runtimeProgressSink) string { result := s.importIssue(input, progress) - provider := s.runProviderTurn(input, result, progress) - if provider.Configured { - if strings.TrimSpace(provider.Output) != "" && provider.Err == nil { - return strings.TrimSpace(provider.Output) - } - return formatProviderFailureAnswer(provider, result) - } return multicasurface.FormatRuntimeFinalAnswer(runtimeResultSummary(result)) } +func runtimeHasIssueActivation(env []string, cwd string, input multicasurface.RuntimeInput) bool { + activation := multicasurface.RuntimeContextFromActivation(env, cwd, multicasurface.RuntimeInput{}) + if strings.TrimSpace(activation.IssueIdentity) != "" { + return true + } + activation = multicasurface.RuntimeContextFromActivation(env, cwd, input) + return strings.TrimSpace(activation.IssueIdentity) != "" +} + +func runProviderStdioProxy(cfg runtimeConfig, cwd string) error { + command := runtimeProviderCommand(cfg.Env) + if command == "" { + return fmt.Errorf("no provider command configured") + } + ctx := context.Background() + cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command) + cmd.Dir = cwd + cmd.Env = append([]string(nil), cfg.Env...) + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("provider stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("provider stdout pipe: %w", err) + } + cmd.Stderr = cfg.Stderr + if err := cmd.Start(); err != nil { + return fmt.Errorf("start provider app-server: %w", err) + } + copyErr := make(chan error, 2) + go func() { + _, err := io.Copy(stdin, cfg.Stdin) + _ = stdin.Close() + copyErr <- err + }() + go func() { + _, err := io.Copy(cfg.Stdout, stdout) + copyErr <- err + }() + stdinErr := <-copyErr + stdoutErr := <-copyErr + waitErr := cmd.Wait() + if stdinErr != nil { + return fmt.Errorf("copy runtime input to provider: %w", stdinErr) + } + if stdoutErr != nil { + return fmt.Errorf("copy provider output to runtime: %w", stdoutErr) + } + if waitErr != nil { + return fmt.Errorf("provider app-server exited: %w", waitErr) + } + return nil +} + +func runtimeProviderCommand(env []string) string { + if command := multicasurface.RuntimeEnvValue(env, "MNEMON_MULTICA_PROVIDER_COMMAND"); command != "" { + return command + } + switch strings.ToLower(multicasurface.RuntimeEnvDefault(env, "MNEMON_MULTICA_PROVIDER_RUNTIME", "codex")) { + case "codex", "codex-jsonrpc": + return "codex app-server --listen stdio://" + default: + return "" + } +} + func (s *runtimeRPCState) importIssue(input multicasurface.RuntimeInput, progress runtimeProgressSink) runtimeImportResult { activation := multicasurface.RuntimeContextFromActivation(s.Env, s.CWD, input) taskID := activation.TaskID @@ -403,126 +514,6 @@ func (s *runtimeRPCState) importIssue(input multicasurface.RuntimeInput, progres return result } -func (s *runtimeRPCState) runProviderTurn(input multicasurface.RuntimeInput, imported runtimeImportResult, progress runtimeProgressSink) providerTurnResult { - command := multicasurface.RuntimeEnvValue(s.Env, "MNEMON_MULTICA_PROVIDER_COMMAND") - if command == "" { - return providerTurnResult{} - } - provider := multicasurface.RuntimeEnvDefault(s.Env, "MNEMON_MULTICA_PROVIDER_RUNTIME", "provider") - cwd := multicasurface.RuntimeEnvDefault(s.Env, "MNEMON_MULTICA_PROVIDER_WORKSPACE", s.CWD) - prompt := providerPrompt(input, imported) - timeout := multicasurface.RuntimeProviderTurnTimeout(s.Env) - result := providerTurnResult{ - Configured: true, - Runtime: provider, - Command: command, - CWD: cwd, - } - emitRuntimeProgress(progress, "Starting Multica-hosted provider turn through "+provider+".") - started := s.now() - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command) - cmd.Dir = cwd - cmd.Env = runtimeProviderEnv(s.Env, imported) - cmd.Stdin = strings.NewReader(prompt) - out, err := cmd.CombinedOutput() - result.DurationMs = s.now().Sub(started).Milliseconds() - result.Output = strings.TrimSpace(string(out)) - if err != nil { - result.Err = err - result.ExitCode = runtimeExitCode(err) - if exitErr, ok := err.(*exec.ExitError); ok { - result.ExitCode = exitErr.ExitCode() - } - if ctx.Err() == context.DeadlineExceeded { - result.Err = fmt.Errorf("provider turn timed out after %s", timeout) - } - emitRuntimeCommandCWD(progress, command, cwd, result.Output, result.ExitCode, result.DurationMs) - emitRuntimeProgress(progress, "Provider turn failed; Mnemon surface import remains separate from provider execution.") - return result - } - result.ExitCode = 0 - emitRuntimeCommandCWD(progress, command, cwd, result.Output, result.ExitCode, result.DurationMs) - emitRuntimeProgress(progress, "Provider turn completed; Mnemon did not rewrite provider output.") - return result -} - -func runtimeProviderEnv(env []string, imported runtimeImportResult) []string { - out := append([]string(nil), env...) - out = setRuntimeProviderEnv(out, "MULTICA_ISSUE_ID", imported.IssueID) - out = setRuntimeProviderEnv(out, "MULTICA_TASK_ID", imported.TaskID) - out = setRuntimeProviderEnv(out, "MNEMON_MULTICA_ISSUE_ID", imported.IssueID) - out = setRuntimeProviderEnv(out, "MNEMON_MULTICA_ISSUE_IDENTIFIER", imported.Identifier) - out = setRuntimeProviderEnv(out, "MNEMON_MULTICA_ISSUE_TITLE", imported.Title) - out = setRuntimeProviderEnv(out, "MNEMON_MULTICA_ISSUE_STATUS", imported.Status) - out = setRuntimeProviderEnv(out, "MNEMON_MULTICA_PRINCIPAL", imported.Principal) - return out -} - -func setRuntimeProviderEnv(env []string, key, value string) []string { - key = strings.TrimSpace(key) - value = strings.TrimSpace(value) - if key == "" || value == "" { - return env - } - next := make([]string, 0, len(env)+1) - prefix := key + "=" - for _, entry := range env { - if strings.HasPrefix(entry, prefix) { - continue - } - next = append(next, entry) - } - return append(next, key+"="+value) -} - -func providerPrompt(input multicasurface.RuntimeInput, imported runtimeImportResult) string { - if text := strings.TrimSpace(input.Text); text != "" { - return text + "\n" - } - var b strings.Builder - label := firstNonEmpty(imported.Identifier, imported.IssueID) - if label != "" || strings.TrimSpace(imported.Title) != "" { - b.WriteString("Multica issue") - if label != "" { - b.WriteByte(' ') - b.WriteString(label) - } - if strings.TrimSpace(imported.Title) != "" { - b.WriteString(": ") - b.WriteString(strings.TrimSpace(imported.Title)) - } - b.WriteString("\n\n") - } - if strings.TrimSpace(imported.Statement) != "" { - b.WriteString(strings.TrimSpace(imported.Statement)) - b.WriteByte('\n') - } - return b.String() -} - -func formatProviderFailureAnswer(provider providerTurnResult, imported runtimeImportResult) string { - var b strings.Builder - b.WriteString("Provider turn failed") - if provider.Runtime != "" { - b.WriteString(" (") - b.WriteString(provider.Runtime) - b.WriteString(")") - } - if provider.Err != nil { - b.WriteString(": ") - b.WriteString(provider.Err.Error()) - } - if strings.TrimSpace(provider.Output) != "" { - b.WriteString("\n\n") - b.WriteString(strings.TrimSpace(provider.Output)) - } - b.WriteString("\n\n") - b.WriteString(multicasurface.FormatRuntimeFinalAnswer(runtimeResultSummary(imported))) - return strings.TrimSpace(b.String()) -} - func loadRuntimeIssueMetadata(ctx context.Context, cli driver.MulticaCLI, issue driver.MulticaIssue, progress runtimeProgressSink) driver.MulticaIssue { if strings.TrimSpace(issue.ID) == "" { return issue diff --git a/harness/cmd/mnemon-multica-runtime/main_test.go b/harness/cmd/mnemon-multica-runtime/main_test.go index a3e7650f..4ab0e231 100644 --- a/harness/cmd/mnemon-multica-runtime/main_test.go +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -115,19 +115,55 @@ func TestRuntimeTreatsLegacyMailboxMetadataAsSurfaceInputOnly(t *testing.T) { } } -func TestRuntimeRunsProviderCommandWithOriginalTurnInput(t *testing.T) { +func TestRuntimeProxiesProviderAppServerWhenNoIssueGateIsPresent(t *testing.T) { + tmp := t.TempDir() + providerLogPath := filepath.Join(tmp, "provider-rpc.jsonl") + providerBin := writeFakeCodexAppServer(t, providerLogPath) + input := strings.Join([]string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"multica","version":"test"}}}`, + `{"jsonrpc":"2.0","method":"initialized"}`, + `{"jsonrpc":"2.0","id":2,"method":"thread/start","params":{"cwd":"` + tmp + `","ephemeral":true}}`, + `{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"threadId":"thread-fake","input":[{"type":"text","text":"你好,请保持原生 Codex 流。"}]}}`, + }, "\n") + "\n" + var out strings.Builder + err := runRuntime(runtimeConfig{ + Env: []string{ + "MNEMON_MULTICA_PROVIDER_RUNTIME=codex", + "MNEMON_MULTICA_PROVIDER_COMMAND=" + providerBin, + "FAKE_CODEX_APP_SERVER_LOG=" + providerLogPath, + }, + CWD: tmp, + Stdin: strings.NewReader(input), + Stdout: &out, + Now: fixedRuntimeTime, + }) + if err != nil { + t.Fatal(err) + } + providerLog := readFile(t, providerLogPath) + for _, want := range []string{`"method":"initialize"`, `"method":"thread/start"`, `"method":"turn/start"`, "你好,请保持原生 Codex 流。"} { + if !strings.Contains(providerLog, want) { + t.Fatalf("provider did not receive %q:\n%s", want, providerLog) + } + } + for _, want := range []string{`"provider":"fake-codex"`, `"method":"item/agentMessage/delta"`, "原生 provider 输出:已读取中文 turn"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("runtime did not proxy provider output %q:\n%s", want, out.String()) + } + } +} + +func TestRuntimeGateDoesNotStartProviderForIssueActivation(t *testing.T) { tmp := t.TempDir() logPath := filepath.Join(tmp, "multica-args.log") + providerLogPath := filepath.Join(tmp, "provider-rpc.jsonl") multicaBin := writeFakeMulticaCLI(t, logPath, fakeMulticaScript{ IssueJSON: `{"id":"issue-provider","identifier":"TEA-9","title":"中文验收-多角色决策","description":"请协调多角色完成退款策略复盘。","status":"todo","priority":"high"}`, MetadataJSON: `[]`, }) server, received := fakeIngestServer(t) defer server.Close() - providerInputPath := filepath.Join(tmp, "provider.stdin") - providerOutput := "原生 provider 输出:已读取中文 turn" - providerBin := writeFakeProvider(t) - progress := []runtimeProgressEvent{} + providerBin := writeFakeCodexAppServer(t, providerLogPath) final := (&runtimeRPCState{ Env: []string{ @@ -140,97 +176,85 @@ func TestRuntimeRunsProviderCommandWithOriginalTurnInput(t *testing.T) { "MNEMON_CONTROL_PRINCIPAL=reviewer@team", "MNEMON_MULTICA_PROVIDER_RUNTIME=codex", "MNEMON_MULTICA_PROVIDER_COMMAND=" + providerBin, - "MNEMON_MULTICA_PROVIDER_WORKSPACE=" + tmp, - "MNEMON_MULTICA_PROVIDER_TURN_TIMEOUT=5s", - "FAKE_PROVIDER_INPUT=" + providerInputPath, - "FAKE_PROVIDER_OUTPUT=" + providerOutput, + "FAKE_CODEX_APP_SERVER_LOG=" + providerLogPath, "FAKE_MULTICA_LOG=" + logPath, }, CWD: tmp, Now: fixedRuntimeTime, - }).runTurn(multicasurface.RuntimeInput{Text: "请基于当前 issue 继续推进中文 ReAct 协作。"}, func(event runtimeProgressEvent) { - progress = append(progress, event) - }) + }).runTurn(multicasurface.RuntimeInput{Text: "请基于当前 issue 继续推进中文 ReAct 协作。"}, nil) - if final != providerOutput { + if !strings.Contains(final, "Mnemon Multica runtime handled issue") { t.Fatalf("final answer = %q", final) } - if got := readFile(t, providerInputPath); got != "请基于当前 issue 继续推进中文 ReAct 协作。\n" { - t.Fatalf("provider input = %q", got) - } env := <-received if env.ExternalID != "multica-task-task-provider" { t.Fatalf("unexpected ingest external id: %+v", env) } - foundProviderCommand := false - for _, event := range progress { - if event.Command == providerBin { - foundProviderCommand = true - if event.CWD != tmp { - t.Fatalf("provider cwd = %q, want %q", event.CWD, tmp) - } - } - } - if !foundProviderCommand { - t.Fatalf("provider command was not emitted in progress: %+v", progress) - } - argsLog := readFile(t, logPath) - if strings.Contains(argsLog, "issue comment add") || - strings.Contains(argsLog, "issue status set") || - strings.Contains(argsLog, "[mnemon:wake]") { - t.Fatalf("provider wrapper performed forbidden R2 side effect:\n%s", argsLog) + if got := readFile(t, providerLogPath); strings.TrimSpace(got) != "" { + t.Fatalf("issue activation bypassed gate and started provider:\n%s", got) } } -func TestRuntimeProviderEnvAddsImportedIssueContext(t *testing.T) { - env := runtimeProviderEnv([]string{ - "MULTICA_TASK_ID=daemon-task", - "MULTICA_AGENT_ID=agent-1", - }, runtimeImportResult{ - IssueID: "issue-imported", - Identifier: "TEA-42", - Title: "中文协作验收", - Principal: "planner@team", - TaskID: "task-imported", - Status: "recorded", +func TestRuntimeGateUsesTurnIssueTagBeforeStartingProvider(t *testing.T) { + tmp := t.TempDir() + logPath := filepath.Join(tmp, "multica-args.log") + providerLogPath := filepath.Join(tmp, "provider-rpc.jsonl") + multicaBin := writeFakeMulticaCLI(t, logPath, fakeMulticaScript{ + IssueJSON: `{"id":"issue-tagged","identifier":"TEA-9","title":"中文验收-由 @tag 触发","description":"请根据 @TEA-9 进入 Mnemon gate。","status":"todo","priority":"high"}`, + MetadataJSON: `[]`, }) - got := map[string]string{} - counts := map[string]int{} - for _, entry := range env { - key, value, ok := strings.Cut(entry, "=") - if ok { - got[key] = value - counts[key]++ - } + server, received := fakeIngestServer(t) + defer server.Close() + providerBin := writeFakeCodexAppServer(t, providerLogPath) + input := strings.Join([]string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"multica","version":"test"}}}`, + `{"jsonrpc":"2.0","id":2,"method":"thread/start","params":{"cwd":"` + tmp + `","ephemeral":true}}`, + `{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"threadId":"thread-fake","input":[{"type":"text","text":"请处理 @TEA-9 并进入 Mnemon 协作流程。"}]}}`, + }, "\n") + "\n" + var out strings.Builder + + err := runRuntime(runtimeConfig{ + Env: []string{ + "MULTICA_TASK_ID=task-tagged", + "MULTICA_AGENT_ID=agent-1", + "MULTICA_AGENT_NAME=Reviewer", + "MNEMON_MULTICA_BIN=" + multicaBin, + "MNEMON_CONTROL_ADDR=" + server.URL, + "MNEMON_CONTROL_PRINCIPAL=reviewer@team", + "MNEMON_MULTICA_PROVIDER_RUNTIME=codex", + "MNEMON_MULTICA_PROVIDER_COMMAND=" + providerBin, + "FAKE_CODEX_APP_SERVER_LOG=" + providerLogPath, + "FAKE_MULTICA_LOG=" + logPath, + }, + CWD: tmp, + Stdin: strings.NewReader(input), + Stdout: &out, + Now: fixedRuntimeTime, + }) + if err != nil { + t.Fatal(err) } - for key, want := range map[string]string{ - "MULTICA_ISSUE_ID": "issue-imported", - "MULTICA_TASK_ID": "task-imported", - "MNEMON_MULTICA_ISSUE_IDENTIFIER": "TEA-42", - "MNEMON_MULTICA_ISSUE_TITLE": "中文协作验收", - "MNEMON_MULTICA_ISSUE_STATUS": "recorded", - "MNEMON_MULTICA_PRINCIPAL": "planner@team", - } { - if got[key] != want { - t.Fatalf("%s = %q, want %q (env=%v)", key, got[key], want, env) - } - if counts[key] != 1 { - t.Fatalf("%s appears %d times in env=%v", key, counts[key], env) - } + env := <-received + if env.ExternalID != "multica-task-task-tagged" { + t.Fatalf("unexpected ingest external id: %+v", env) + } + if got := readFile(t, providerLogPath); strings.TrimSpace(got) != "" { + t.Fatalf("turn issue gate started provider before mnemond import:\n%s", got) + } + if !strings.Contains(out.String(), "Mnemon Multica runtime handled issue") { + t.Fatalf("runtime output did not include Mnemon result:\n%s", out.String()) } } -func TestProviderPromptFallsBackToIssueWhenTurnInputIsEmpty(t *testing.T) { - got := providerPrompt(multicasurface.RuntimeInput{}, runtimeImportResult{ - IssueID: "issue-1", - Identifier: "TEA-1", - Title: "中文复用上下文评审", - Statement: "请复用上一轮风险结论,并给出下一步。", - }) - for _, want := range []string{"TEA-1", "中文复用上下文评审", "请复用上一轮风险结论"} { - if !strings.Contains(got, want) { - t.Fatalf("provider prompt missing %q:\n%s", want, got) - } +func TestRuntimeProviderCommandDefaultsToCodexJSONRPC(t *testing.T) { + if got := runtimeProviderCommand(nil); got != "codex app-server --listen stdio://" { + t.Fatalf("default provider command = %q", got) + } + if got := runtimeProviderCommand([]string{"MNEMON_MULTICA_PROVIDER_RUNTIME=codex-jsonrpc"}); got != "codex app-server --listen stdio://" { + t.Fatalf("codex-jsonrpc provider command = %q", got) + } + if got := runtimeProviderCommand([]string{"MNEMON_MULTICA_PROVIDER_RUNTIME=unknown"}); got != "" { + t.Fatalf("unknown provider command = %q", got) } } @@ -267,17 +291,39 @@ esac return path } -func writeFakeProvider(t *testing.T) string { +func writeFakeCodexAppServer(t *testing.T, logPath string) string { t.Helper() - path := filepath.Join(t.TempDir(), "provider") + path := filepath.Join(t.TempDir(), "fake-codex-app-server") body := `#!/bin/sh set -eu -cat > "$FAKE_PROVIDER_INPUT" -printf '%s\n' "$FAKE_PROVIDER_OUTPUT" +: > "$FAKE_CODEX_APP_SERVER_LOG" +while IFS= read -r line; do + printf '%s\n' "$line" >> "$FAKE_CODEX_APP_SERVER_LOG" + case "$line" in + *'"method":"initialize"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"provider":"fake-codex","userAgent":"fake-codex/0.1"}}' + ;; + *'"method":"thread/start"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thread-fake","sessionId":"thread-fake","status":{"type":"idle"}}}}' + printf '%s\n' '{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"thread-fake","sessionId":"thread-fake","status":{"type":"idle"}}}}' + ;; + *'"method":"turn/start"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"turn":{"id":"turn-fake","status":"inProgress","items":[]}}}' + printf '%s\n' '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thread-fake","turn":{"id":"turn-fake","status":"inProgress","items":[]}}}' + printf '%s\n' '{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"threadId":"thread-fake","turnId":"turn-fake","itemId":"msg-fake","delta":"原生 provider 输出:已读取中文 turn"}}' + printf '%s\n' '{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thread-fake","turn":{"id":"turn-fake","status":"completed","items":[]}}}' + exit 0 + ;; + esac +done ` if err := os.WriteFile(path, []byte(body), 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(logPath, nil, 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("FAKE_CODEX_APP_SERVER_LOG", logPath) return path } diff --git a/harness/internal/app/sync_github_live_test.go b/harness/internal/app/sync_github_live_test.go deleted file mode 100644 index 13bdb5c7..00000000 --- a/harness/internal/app/sync_github_live_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package app - -import ( - "context" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/mnemon-dev/mnemon/harness/internal/contract" - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" - githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" - "github.com/mnemon-dev/mnemon/harness/internal/runtime" -) - -func TestGitHubLivePublishPullImport(t *testing.T) { - cfg := liveGitHubTestConfig(t) - store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ - Repo: cfg.repo, - Token: cfg.token, - HTTPClient: &http.Client{ - Timeout: 30 * time.Second, - }, - }) - if err != nil { - t.Fatalf("github publication store: %v", err) - } - if err := store.EnsureBranches(context.Background(), []string{cfg.branchA, cfg.branchB}, "main"); err != nil { - t.Fatalf("ensure live GitHub branches: %v", err) - } - remoteA := liveGitHubRemote(t, store, cfg.repo, cfg.branchA) - remoteB := liveGitHubRemote(t, store, cfg.repo, cfg.branchB) - - sourcePrincipal := contract.ActorID("codex-github-live-a@project") - targetPrincipal := contract.ActorID("codex-github-live-b@project") - source := openMeshServingRuntime(t, filepath.Join(t.TempDir(), "source"), string(sourcePrincipal)) - target := openMeshServingRuntime(t, filepath.Join(t.TempDir(), "target"), string(targetPrincipal)) - - runID := "github-live-" + time.Now().UTC().Format("20060102T150405.000000000Z") - assignmentID := runID + "-assignment" - assignmentScope := "github-live/publish-pull-import/" + runID - observeLiveAssignment(t, source, sourcePrincipal, assignmentID, assignmentScope, targetPrincipal) - - if err := syncWorkerPush(source, remoteA, "github-live-publish-a"); err != nil { - t.Fatalf("source publish branch %s: %v", cfg.branchA, err) - } - if pending, err := source.PendingSyncedEvents(); err != nil || len(pending) != 0 { - t.Fatalf("source publish must drain pending events, pending=%+v err=%v", pending, err) - } - - if err := syncWorkerPull(target, remoteA, "github-live-subscribe-a", nil); err != nil { - t.Fatalf("target pull branch %s: %v", cfg.branchA, err) - } - assertAssignmentScopeCount(t, target, assignmentScope, 1) - if err := syncWorkerPull(target, remoteA, "github-live-subscribe-a", nil); err != nil { - t.Fatalf("target repeat pull branch %s: %v", cfg.branchA, err) - } - assertAssignmentScopeCount(t, target, assignmentScope, 1) - - progressSummary := runID + " completed assignment " + assignmentID - observeLiveProgress(t, target, targetPrincipal, runID+"-progress", progressSummary) - if err := syncWorkerPush(target, remoteB, "github-live-publish-b"); err != nil { - t.Fatalf("target publish branch %s: %v", cfg.branchB, err) - } - if pending, err := target.PendingSyncedEvents(); err != nil || len(pending) != 0 { - t.Fatalf("target publish must drain pending events, pending=%+v err=%v", pending, err) - } - - if err := syncWorkerPull(source, remoteB, "github-live-subscribe-b", nil); err != nil { - t.Fatalf("source pull branch %s: %v", cfg.branchB, err) - } - assertProgressSummaryCount(t, source, progressSummary, 1) - if err := syncWorkerPull(source, remoteB, "github-live-subscribe-b", nil); err != nil { - t.Fatalf("source repeat pull branch %s: %v", cfg.branchB, err) - } - assertProgressSummaryCount(t, source, progressSummary, 1) -} - -type liveGitHubConfig struct { - repo string - branchA string - branchB string - token string -} - -func liveGitHubTestConfig(t *testing.T) liveGitHubConfig { - t.Helper() - if os.Getenv("MNEMON_GITHUB_LIVE") != "1" { - t.Skip("set MNEMON_GITHUB_LIVE=1 to run the real GitHub publish/pull/import test") - } - token := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) - if tokenFile := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_TOKEN_FILE")); tokenFile != "" { - raw, err := os.ReadFile(tokenFile) - if err != nil { - t.Fatalf("read MNEMON_GITHUB_TOKEN_FILE: %v", err) - } - token = strings.TrimSpace(string(raw)) - } - if token == "" { - t.Skip("GITHUB_TOKEN or MNEMON_GITHUB_TOKEN_FILE is required for live GitHub publish/pull/import test") - } - repo := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_REPO")) - if repo == "" { - repo = "mnemon-dev/mnemon-teamwork-example" - } - branchA := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH_A")) - if branchA == "" { - branchA = "mnemon/live/" + time.Now().UTC().Format("20060102T150405.000000000Z") + "/a" - } - branchB := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH_B")) - if branchB == "" { - branchB = strings.TrimSuffix(branchA, "/a") + "/b" - } - return liveGitHubConfig{repo: repo, branchA: branchA, branchB: branchB, token: token} -} - -func liveGitHubRemote(t *testing.T, store exchange.PublicationStore, repo, branch string) exchange.RemoteWorkspace { - t.Helper() - remote, err := githubbackend.New(githubbackend.Config{ - Store: store, - Repo: repo, - Branch: branch, - Scopes: []contract.ResourceRef{ - {Kind: "assignment", ID: "project"}, - {Kind: "progress_digest", ID: "project"}, - }, - }) - if err != nil { - t.Fatalf("github remote %s: %v", branch, err) - } - return remote -} - -func observeLiveAssignment(t *testing.T, rt *runtime.Runtime, principal contract.ActorID, assignmentID, scope string, assignee contract.ActorID) { - t.Helper() - if _, _, err := rt.API().Ingest(principal, contract.ObservationEnvelope{ - ExternalID: assignmentID, - Event: contract.Event{Type: "assignment.write_candidate.observed", Payload: r2AssignmentPayload( - map[string]any{"assignment_id": assignmentID, "scope": scope, "ttl": "30m", "assignee": string(assignee)}, - map[string]any{"expected_work": "complete live GitHub Remote Workspace publish/pull/import validation", "expected_feedback": "progress_digest with result evidence"}, - map[string]any{"evidence_refs": []any{"gated live GitHub publication backend test"}}, - )}, - }); err != nil { - t.Fatalf("observe live assignment: %v", err) - } - if _, err := rt.Tick(); err != nil { - t.Fatalf("tick live assignment: %v", err) - } -} - -func observeLiveProgress(t *testing.T, rt *runtime.Runtime, principal contract.ActorID, externalID, summary string) { - t.Helper() - if _, _, err := rt.API().Ingest(principal, contract.ObservationEnvelope{ - ExternalID: externalID, - Event: contract.Event{Type: "progress_digest.write_candidate.observed", Payload: r2Progress(summary)}, - }); err != nil { - t.Fatalf("observe live progress: %v", err) - } - if _, err := rt.Tick(); err != nil { - t.Fatalf("tick live progress: %v", err) - } -} - -func assertAssignmentScopeCount(t *testing.T, rt *runtime.Runtime, scope string, want int) { - t.Helper() - _, fields, err := rt.Resource(contract.ResourceRef{Kind: "assignment", ID: "project"}) - if err != nil { - t.Fatalf("read assignment: %v", err) - } - content, _ := fields["content"].(string) - if got := strings.Count(content, scope); got != want { - t.Fatalf("assignment scope %q count = %d, want %d\n%s", scope, got, want, content) - } -} - -func assertProgressSummaryCount(t *testing.T, rt *runtime.Runtime, summary string, want int) { - t.Helper() - _, fields, err := rt.Resource(contract.ResourceRef{Kind: "progress_digest", ID: "project"}) - if err != nil { - t.Fatalf("read progress_digest: %v", err) - } - content, _ := fields["content"].(string) - if got := strings.Count(content, summary); got != want { - t.Fatalf("progress summary %q count = %d, want %d\n%s", summary, got, want, content) - } -} diff --git a/harness/internal/app/sync_github_mesh_test.go b/harness/internal/app/sync_github_mesh_test.go deleted file mode 100644 index 0f9d0488..00000000 --- a/harness/internal/app/sync_github_mesh_test.go +++ /dev/null @@ -1,278 +0,0 @@ -package app - -import ( - "fmt" - "path/filepath" - "testing" - - "github.com/mnemon-dev/mnemon/harness/internal/contract" - "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" - githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" - "github.com/mnemon-dev/mnemon/harness/internal/runtime" -) - -func TestSyncGitHubFakeFiveMnemondPublicationMesh(t *testing.T) { - ids := []string{"agent-a", "agent-b", "agent-c", "agent-d", "agent-e"} - branches := make([]string, 0, len(ids)) - for _, id := range ids { - branches = append(branches, "mnemon/"+id) - } - store, err := exchange.NewMemoryPublicationStore(branches...) - if err != nil { - t.Fatal(err) - } - type node struct { - id string - branch string - principal string - rt *runtime.Runtime - } - nodes := make([]node, 0, len(ids)) - for i, id := range ids { - root := t.TempDir() - principal := "codex-" + id + "@project" - rt := openMeshServingRuntime(t, root, principal) - observeMeshAssignment(t, rt, principal, id) - nodes = append(nodes, node{id: id, branch: branches[i], principal: principal, rt: rt}) - } - - for _, n := range nodes { - remote := githubFakeRemote(t, store, n.branch) - if err := syncWorkerPush(n.rt, remote, "publish-"+n.id); err != nil { - t.Fatalf("%s publish: %v", n.id, err) - } - if pending, err := n.rt.PendingSyncedEvents(); err != nil || len(pending) != 0 { - t.Fatalf("%s publish must drain pending events, pending=%+v err=%v", n.id, pending, err) - } - } - - for _, n := range nodes { - for _, source := range nodes { - if source.id == n.id { - continue - } - remote := githubFakeRemote(t, store, source.branch) - state, err := exchange.ReadPullState(n.rt, "subscribe-"+source.id) - if err != nil { - t.Fatalf("%s read pull state %s: %v", n.id, source.id, err) - } - probe, err := remote.SyncPull(contract.SyncPullRequest{ReplicaID: state.ReplicaID, RemoteCursor: state.RemoteCursor}) - if err != nil { - t.Fatalf("%s probe %s: %v", n.id, source.id, err) - } - if len(probe.Diagnostics) > 0 || len(probe.Events) == 0 { - t.Fatalf("%s probe %s returned events=%d diagnostics=%+v", n.id, source.id, len(probe.Events), probe.Diagnostics) - } - if err := syncWorkerPull(n.rt, remote, "subscribe-"+source.id, nil); err != nil { - t.Fatalf("%s subscribe %s: %v", n.id, source.id, err) - } - } - } - - assignmentRef := contract.ResourceRef{Kind: "assignment", ID: "project"} - for _, n := range nodes { - _, fields, err := n.rt.Resource(assignmentRef) - if err != nil { - t.Fatalf("%s read assignments: %v", n.id, err) - } - items, _ := fields["items"].([]any) - scopes := map[string]bool{} - for _, item := range items { - m, _ := item.(map[string]any) - scopes[towerItemString(m, "scope")] = true - } - for _, source := range nodes { - want := meshAssignmentScope(source.id) - if !scopes[want] { - t.Fatalf("%s assignments missing %q in %+v", n.id, want, items) - } - } - } -} - -func TestSyncGitHubFakePublicationMeshJoinLeaveReassignment(t *testing.T) { - ids := []string{"agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f", "agent-g"} - branches := make([]string, 0, len(ids)) - for _, id := range ids { - branches = append(branches, "mnemon/"+id) - } - store, err := exchange.NewMemoryPublicationStore(branches...) - if err != nil { - t.Fatal(err) - } - - nodes := make([]meshTestNode, 0, len(ids)) - for _, id := range ids[:5] { - node := newMeshTestNode(t, id) - observeMeshAssignment(t, node.rt, node.principal, id) - publishMeshNode(t, store, node) - nodes = append(nodes, node) - } - pullMeshAllSources(t, store, nodes[:5], nodes[:5]) - - for _, id := range ids[5:] { - node := newMeshTestNode(t, id) - observeMeshAssignment(t, node.rt, node.principal, id) - publishMeshNode(t, store, node) - nodes = append(nodes, node) - } - offline := nodes[3] - active := append([]meshTestNode{}, nodes[:3]...) - active = append(active, nodes[4:]...) - reassignScope := "agent-d down; reassign delayed work to agent-f" - observeMeshAssignmentWithScope(t, nodes[0].rt, nodes[0].principal, "reassign-agent-d", reassignScope, "codex@agent-f") - publishMeshNode(t, store, nodes[0]) - - pullMeshAllSources(t, store, active, nodes) - assertMeshScopes(t, active, []string{ - meshAssignmentScope("agent-a"), - meshAssignmentScope("agent-b"), - meshAssignmentScope("agent-c"), - meshAssignmentScope("agent-d"), - meshAssignmentScope("agent-e"), - meshAssignmentScope("agent-f"), - meshAssignmentScope("agent-g"), - reassignScope, - }) - assertMeshScopesAbsent(t, []meshTestNode{offline}, []string{meshAssignmentScope("agent-f"), meshAssignmentScope("agent-g"), reassignScope}) - - pullMeshAllSources(t, store, []meshTestNode{offline}, nodes) - assertMeshScopes(t, []meshTestNode{offline}, []string{meshAssignmentScope("agent-f"), meshAssignmentScope("agent-g"), reassignScope}) -} - -type meshTestNode struct { - id string - branch string - principal string - rt *runtime.Runtime -} - -func newMeshTestNode(t *testing.T, id string) meshTestNode { - t.Helper() - root := t.TempDir() - principal := "codex-" + id + "@project" - return meshTestNode{ - id: id, - branch: "mnemon/" + id, - principal: principal, - rt: openMeshServingRuntime(t, root, principal), - } -} - -func publishMeshNode(t *testing.T, store exchange.PublicationStore, node meshTestNode) { - t.Helper() - remote := githubFakeRemote(t, store, node.branch) - if err := syncWorkerPush(node.rt, remote, "publish-"+node.id); err != nil { - t.Fatalf("%s publish: %v", node.id, err) - } - if pending, err := node.rt.PendingSyncedEvents(); err != nil || len(pending) != 0 { - t.Fatalf("%s publish must drain pending events, pending=%+v err=%v", node.id, pending, err) - } -} - -func pullMeshAllSources(t *testing.T, store exchange.PublicationStore, targets, sources []meshTestNode) { - t.Helper() - for _, target := range targets { - for _, source := range sources { - if source.id == target.id { - continue - } - remote := githubFakeRemote(t, store, source.branch) - if err := syncWorkerPull(target.rt, remote, "subscribe-"+source.id, nil); err != nil { - t.Fatalf("%s subscribe %s: %v", target.id, source.id, err) - } - } - } -} - -func assertMeshScopes(t *testing.T, nodes []meshTestNode, scopes []string) { - t.Helper() - for _, node := range nodes { - got := meshScopes(t, node) - for _, want := range scopes { - if !got[want] { - t.Fatalf("%s assignments missing %q in %+v", node.id, want, got) - } - } - } -} - -func assertMeshScopesAbsent(t *testing.T, nodes []meshTestNode, scopes []string) { - t.Helper() - for _, node := range nodes { - got := meshScopes(t, node) - for _, want := range scopes { - if got[want] { - t.Fatalf("%s assignments unexpectedly contain %q in %+v", node.id, want, got) - } - } - } -} - -func meshScopes(t *testing.T, node meshTestNode) map[string]bool { - t.Helper() - assignmentRef := contract.ResourceRef{Kind: "assignment", ID: "project"} - _, fields, err := node.rt.Resource(assignmentRef) - if err != nil { - t.Fatalf("%s read assignments: %v", node.id, err) - } - items, _ := fields["items"].([]any) - scopes := map[string]bool{} - for _, item := range items { - m, _ := item.(map[string]any) - scopes[towerItemString(m, "scope")] = true - } - return scopes -} - -func openMeshServingRuntime(t *testing.T, root, principal string) *runtime.Runtime { - t.Helper() - refs := []contract.ResourceRef{{Kind: "progress_digest", ID: "project"}, {Kind: "assignment", ID: "project"}} - b := access.HostAgentBinding(contract.ActorID(principal), "http://127.0.0.1:8787", refs) - rt, err := OpenLocalRuntime(filepath.Join(root, runtime.DefaultStorePath), access.LoadedBindings{Bindings: []access.ChannelBinding{b}}, nil, nil) - if err != nil { - t.Fatalf("open serving runtime: %v", err) - } - t.Cleanup(func() { _ = rt.Close() }) - return rt -} - -func observeMeshAssignment(t *testing.T, rt *runtime.Runtime, principal, id string) { - t.Helper() - observeMeshAssignmentWithScope(t, rt, principal, id, meshAssignmentScope(id), "codex@"+id) -} - -func observeMeshAssignmentWithScope(t *testing.T, rt *runtime.Runtime, principal, id, scope, assignee string) { - t.Helper() - if _, _, err := rt.API().Ingest(contract.ActorID(principal), contract.ObservationEnvelope{ - ExternalID: "github-mesh-assignment-" + id, - Event: contract.Event{Type: "assignment.write_candidate.observed", Payload: r2AssignmentPayload( - map[string]any{"assignment_id": "mesh-" + id, "scope": scope, "ttl": "2h", "assignee": assignee}, - map[string]any{"expected_work": "complete deterministic publication mesh validation for " + id, "expected_feedback": "progress_digest"}, - map[string]any{"evidence_refs": []any{"deterministic fake GitHub publication mesh test"}}, - )}, - }); err != nil { - t.Fatalf("observe assignment: %v", err) - } - if _, err := rt.Tick(); err != nil { - t.Fatalf("tick assignment: %v", err) - } -} - -func meshAssignmentScope(id string) string { - return fmt.Sprintf("%s publication mesh assignment", id) -} - -func githubFakeRemote(t *testing.T, store exchange.PublicationStore, branch string) exchange.RemoteWorkspace { - t.Helper() - remote, err := githubbackend.New(githubbackend.Config{ - Store: store, - Repo: "mnemon-dev/mnemon-teamwork-example", - Branch: branch, - }) - if err != nil { - t.Fatal(err) - } - return remote -} diff --git a/harness/internal/app/sync_worker.go b/harness/internal/app/sync_worker.go index 6cc0e6ac..d0cb6d07 100644 --- a/harness/internal/app/sync_worker.go +++ b/harness/internal/app/sync_worker.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "net/http" "os" "path/filepath" "strconv" @@ -15,7 +14,6 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/policy" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" - githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" "github.com/mnemon-dev/mnemon/harness/internal/runtime" ) @@ -150,15 +148,12 @@ func syncWorkerPass(rt *runtime.Runtime, opts SyncWorkerOptions) error { return nil } -// syncWorkerRemote builds the selected Remote Workspace backend from the remote entry. Today only -// the first-party HTTP mnemon-hub backend is implemented; the sync loop above depends only on the -// exchange.RemoteWorkspace ABI so a future GitHub publication mesh does not touch runtime import. +// syncWorkerRemote builds the selected Remote Workspace backend from the remote entry. The +// first-party backend is the HTTP MnemonHub wire; product surfaces must not bypass local import. func syncWorkerRemote(entry exchange.RemoteEntry, opts SyncWorkerOptions) (exchange.RemoteWorkspace, error) { switch entry.NormalizedBackend() { case exchange.RemoteBackendHTTP: return syncWorkerHTTPRemote(entry, opts) - case exchange.RemoteBackendGitHub: - return syncWorkerGitHubRemote(entry, opts) default: return nil, fmt.Errorf("Remote Workspace %q: unsupported backend %q", entry.ID, entry.NormalizedBackend()) } @@ -195,49 +190,6 @@ func syncWorkerHTTPRemote(entry exchange.RemoteEntry, opts SyncWorkerOptions) (e }) } -func syncWorkerGitHubRemote(entry exchange.RemoteEntry, opts SyncWorkerOptions) (exchange.RemoteWorkspace, error) { - token, err := syncWorkerRemoteToken(entry, opts) - if err != nil { - return nil, err - } - timeout := opts.Timeout - if timeout <= 0 { - timeout = access.DefaultSyncTimeout - } - store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ - Repo: entry.Repo, - Token: token, - HTTPClient: &http.Client{Timeout: timeout}, - }) - if err != nil { - return nil, err - } - return githubbackend.New(githubbackend.Config{ - Store: store, - Repo: entry.Repo, - Branch: entry.Branch, - }) -} - -func syncWorkerRemoteToken(entry exchange.RemoteEntry, opts SyncWorkerOptions) (string, error) { - if strings.TrimSpace(entry.CredentialRef) == "" { - return "", fmt.Errorf("Remote Workspace %q has no credential_ref", entry.ID) - } - tokPath := entry.CredentialRef - if !filepath.IsAbs(tokPath) { - tokPath = filepath.Join(opts.ProjectRoot, tokPath) - } - raw, err := os.ReadFile(tokPath) - if err != nil { - return "", fmt.Errorf("read Remote Workspace token file: %w", err) - } - token := strings.TrimSpace(string(raw)) - if token == "" { - return "", fmt.Errorf("Remote Workspace token file %s is empty", entry.CredentialRef) - } - return token, nil -} - // syncWorkerPush pushes the pending batch (if any) and mirrors the hub's per-event verdicts into // the local ledger — both through the live handle. func syncWorkerPush(rt *runtime.Runtime, remote exchange.RemoteWorkspace, remoteID string) error { diff --git a/harness/internal/app/sync_worker_test.go b/harness/internal/app/sync_worker_test.go index 58dcb1bb..191c66d4 100644 --- a/harness/internal/app/sync_worker_test.go +++ b/harness/internal/app/sync_worker_test.go @@ -184,19 +184,19 @@ func TestSyncWorkerSurvivesUnreachableRemote(t *testing.T) { } } -func TestSyncWorkerBacksOffGitHubRateLimit(t *testing.T) { +func TestSyncWorkerBacksOffRemoteRateLimit(t *testing.T) { now := time.Unix(100, 0) - err := fmt.Errorf("sync pull failed: github api status 403: API rate limit exceeded (rate_limit_remaining=0, rate_limit_reset=160)") + err := fmt.Errorf("sync pull failed: remote status 429: rate limit exceeded (rate_limit_remaining=0, rate_limit_reset=160)") if got := syncWorkerErrorBackoff(err, now); got != 61*time.Second { t.Fatalf("rate-limit reset backoff = %v, want 61s", got) } - err = fmt.Errorf("sync pull failed: github api status 403: secondary limit (retry_after=12, rate_limit_remaining=0, rate_limit_reset=160)") + err = fmt.Errorf("sync pull failed: remote status 429: secondary limit (retry_after=12, rate_limit_remaining=0, rate_limit_reset=160)") if got := syncWorkerErrorBackoff(err, now); got != 12*time.Second { t.Fatalf("retry-after backoff = %v, want 12s", got) } - err = fmt.Errorf("sync pull failed: github api status 500") + err = fmt.Errorf("sync pull failed: remote status 500") if got := syncWorkerErrorBackoff(err, now); got != 0 { t.Fatalf("non-rate-limit error backoff = %v, want zero", got) } diff --git a/harness/internal/contract/contract.go b/harness/internal/contract/contract.go index b2dce551..96ff00a9 100644 --- a/harness/internal/contract/contract.go +++ b/harness/internal/contract/contract.go @@ -7,8 +7,8 @@ type ResourceKind string // "memory", "goal", "skill" type ResourceID string type Version int64 // per-resource; 1 on create; +1 each accepted write. NEVER global. type ResourceRef struct { - Kind ResourceKind - ID ResourceID + Kind ResourceKind `json:"kind"` + ID ResourceID `json:"id"` } type ResourceVersion struct { Ref ResourceRef diff --git a/harness/internal/coreguard/github_remote_workspace_guard_test.go b/harness/internal/coreguard/github_remote_workspace_guard_test.go deleted file mode 100644 index ccc45a2d..00000000 --- a/harness/internal/coreguard/github_remote_workspace_guard_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package coreguard - -import ( - "go/ast" - "go/parser" - "go/token" - "io/fs" - "path/filepath" - "strconv" - "strings" - "testing" -) - -var githubRemoteWorkspaceCorePackages = []string{ - "runtime", - "mnemond/state", - "mnemond/admission", - "mnemond/presentation", - "hostagent", -} - -var forbiddenGitHubBackendTerms = []string{ - "github issue", - "github issues", - "github pr", - "github pull request", - "github action", - "github actions", - "issue-based", - "pr-based", - "action-based", - "p2p discovery", - "peer discovery", - "node discovery", - "gossip", - "dht", - "routing table", - "nat traversal", - "overlay network", -} - -func TestGitHubRemoteWorkspaceGuardLogicIsNotVacuous(t *testing.T) { - if !isGitHubRemoteBackendImportPath("github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github") { - t.Fatal("GitHub backend import matcher must flag the planned exchange/backend/github package") - } - if isGitHubRemoteBackendImportPath("github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange") { - t.Fatal("plain exchange package must not be treated as GitHub backend") - } - if !isAllowedGitHubBackendDir(filepath.FromSlash("mnemonhub/exchange/backend/github")) { - t.Fatal("planned GitHub backend directory should be allowed under mnemonhub/exchange/backend") - } - if isAllowedGitHubBackendDir(filepath.FromSlash("runtime/github")) { - t.Fatal("GitHub backend outside mnemonhub/exchange/backend must not be allowed") - } - if !hasForbiddenGitHubBackendTerm("use GitHub Issues as assignments") { - t.Fatal("forbidden GitHub teamwork semantic matcher should flag Issue/PR/Actions concepts") - } - if hasForbiddenGitHubBackendTerm("publication branch enumeration") { - t.Fatal("publication terminology should remain allowed") - } -} - -func TestGitHubRemoteWorkspaceBackendDoesNotLeakIntoCore(t *testing.T) { - for _, pkg := range githubRemoteWorkspaceCorePackages { - _, files := packageFiles(t, pkg) - for _, file := range files { - for _, imp := range file.Imports { - path := strings.Trim(imp.Path.Value, `"`) - if isGitHubRemoteBackendImportPath(path) { - t.Errorf("core package %q imports GitHub Remote Workspace backend %q; GitHub must stay below mnemonhub/exchange/backend", pkg, path) - } - } - } - } -} - -func TestGitHubRemoteWorkspaceBackendLocationAndNaming(t *testing.T) { - root := filepath.Clean("..") - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() { - if strings.HasPrefix(entry.Name(), ".") { - return filepath.SkipDir - } - return nil - } - name := entry.Name() - if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { - return nil - } - relFile, err := filepath.Rel(root, path) - if err != nil { - return err - } - relDir := filepath.Dir(relFile) - if pathMentionsGitHubBackend(relDir) && !isAllowedGitHubBackendDir(relDir) { - t.Errorf("GitHub backend source %s is outside mnemonhub/exchange/backend", relFile) - } - if isAllowedGitHubBackendDir(relDir) { - assertGitHubBackendFileUsesPublicationVocabulary(t, path) - } - return nil - }) - if err != nil { - t.Fatalf("walk harness/internal: %v", err) - } -} - -func isGitHubRemoteBackendImportPath(path string) bool { - rel := strings.TrimPrefix(path, "github.com/mnemon-dev/mnemon/") - if !strings.HasPrefix(rel, "harness/internal/mnemonhub/exchange/") { - return false - } - tail := strings.TrimPrefix(rel, "harness/internal/mnemonhub/exchange/") - for _, segment := range strings.Split(tail, "/") { - if strings.Contains(strings.ToLower(segment), "github") { - return true - } - } - return false -} - -func isAllowedGitHubBackendDir(relDir string) bool { - clean := filepath.ToSlash(filepath.Clean(relDir)) - return clean == "mnemonhub/exchange/backend" || - strings.HasPrefix(clean, "mnemonhub/exchange/backend/") -} - -func pathMentionsGitHubBackend(relDir string) bool { - for _, segment := range strings.Split(filepath.ToSlash(relDir), "/") { - if strings.Contains(strings.ToLower(segment), "github") { - return true - } - } - return false -} - -func assertGitHubBackendFileUsesPublicationVocabulary(t *testing.T, path string) { - t.Helper() - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) - if err != nil { - t.Fatalf("parse %s: %v", path, err) - } - ast.Inspect(file, func(node ast.Node) bool { - switch n := node.(type) { - case *ast.Ident: - if hasForbiddenGitHubBackendTerm(splitIdentifierWords(n.Name)) { - t.Errorf("%s uses forbidden GitHub/P2P backend term %q; use repo-mediated publication vocabulary", fset.Position(n.Pos()), n.Name) - } - case *ast.BasicLit: - if n.Kind != token.STRING { - return true - } - value, err := strconv.Unquote(n.Value) - if err != nil { - value = strings.Trim(n.Value, "`\"") - } - if hasForbiddenGitHubBackendTerm(value) { - t.Errorf("%s uses forbidden GitHub/P2P backend literal %q; use repo-mediated publication vocabulary", fset.Position(n.Pos()), value) - } - } - return true - }) -} - -func splitIdentifierWords(name string) string { - var b strings.Builder - var prevLower bool - for _, r := range name { - if r == '_' || r == '-' { - b.WriteByte(' ') - prevLower = false - continue - } - if r >= 'A' && r <= 'Z' { - if prevLower { - b.WriteByte(' ') - } - b.WriteRune(r + ('a' - 'A')) - prevLower = false - continue - } - b.WriteRune(r) - prevLower = r >= 'a' && r <= 'z' - } - return b.String() -} - -func hasForbiddenGitHubBackendTerm(text string) bool { - normalized := strings.ToLower(strings.Join(strings.Fields(strings.ReplaceAll(text, "_", " ")), " ")) - for _, term := range forbiddenGitHubBackendTerms { - if strings.Contains(normalized, term) { - return true - } - } - return false -} diff --git a/harness/internal/coreguard/remote_workspace_no_github_guard_test.go b/harness/internal/coreguard/remote_workspace_no_github_guard_test.go new file mode 100644 index 00000000..ff7f880f --- /dev/null +++ b/harness/internal/coreguard/remote_workspace_no_github_guard_test.go @@ -0,0 +1,126 @@ +package coreguard + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +var forbiddenFirstPartyGitHubTerms = []string{ + "RemoteBackendGitHub", + "ConnectionGitHub", + "GitHubConnection", + "runConnectGitHub", + "syncGitHub", + "GitHubMesh", + "GitHub Mesh", + "github mesh", + "GitHub Remote Workspace", + "GitHub-backed", + "github-repo", + "github-token-file", + "github-branch", + "backend/github", +} + +func TestNoFirstPartyGitHubRemoteWorkspaceBackendPackage(t *testing.T) { + root := filepath.Clean("..") + backendRoot := filepath.Join(root, "mnemonhub", "exchange", "backend") + if _, err := os.Stat(backendRoot); os.IsNotExist(err) { + return + } + err := filepath.WalkDir(backendRoot, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + for _, segment := range strings.Split(filepath.ToSlash(path), "/") { + if strings.EqualFold(segment, "github") { + t.Fatalf("GitHub must not be implemented as a first-party Remote Workspace backend: %s", path) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk Remote Workspace backends: %v", err) + } +} + +func TestNoFirstPartyGitHubConnectionOrBackendSymbols(t *testing.T) { + for _, root := range []string{filepath.Clean(".."), filepath.Clean("../../cmd")} { + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if strings.HasPrefix(entry.Name(), ".") { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + return nil + } + assertNoFirstPartyGitHubTerms(t, path) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + } +} + +func assertNoFirstPartyGitHubTerms(t *testing.T, path string) { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, parser.ParseComments|parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + ast.Inspect(file, func(node ast.Node) bool { + switch n := node.(type) { + case *ast.Ident: + if term := firstForbiddenGitHubTerm(n.Name); term != "" { + t.Errorf("%s uses removed first-party GitHub symbol %q", fset.Position(n.Pos()), term) + } + case *ast.BasicLit: + if n.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(n.Value) + if err != nil { + value = strings.Trim(n.Value, "`\"") + } + if isAllowedModuleImport(value) { + return true + } + if term := firstForbiddenGitHubTerm(value); term != "" { + t.Errorf("%s uses removed first-party GitHub literal %q", fset.Position(n.Pos()), term) + } + case *ast.Comment: + if term := firstForbiddenGitHubTerm(n.Text); term != "" { + t.Errorf("%s documents removed first-party GitHub behavior %q", fset.Position(n.Pos()), term) + } + } + return true + }) +} + +func firstForbiddenGitHubTerm(text string) string { + normalized := strings.ToLower(text) + for _, term := range forbiddenFirstPartyGitHubTerms { + if strings.Contains(normalized, strings.ToLower(term)) { + return term + } + } + return "" +} + +func isAllowedModuleImport(value string) bool { + return strings.HasPrefix(value, "github.com/mnemon-dev/mnemon/") +} diff --git a/harness/internal/daemon/configured_test.go b/harness/internal/daemon/configured_test.go index d3fd5166..49eaa7e7 100644 --- a/harness/internal/daemon/configured_test.go +++ b/harness/internal/daemon/configured_test.go @@ -82,7 +82,7 @@ func TestRoleDetailsDescribeDaemonBoundaries(t *testing.T) { func TestRoleSummaryReflectsConfiguredDaemonRoles(t *testing.T) { cfg := productconfig.Default() - cfg.Daemon.InteractionWatchers = []string{productconfig.ConnectionMultica, productconfig.ConnectionGitHub} + cfg.Daemon.InteractionWatchers = []string{productconfig.ConnectionMultica, productconfig.ConnectionMnemonhub} cfg.Daemon.DriveSources = []string{productconfig.DriveManagedLocal} cfg.Daemon.DisplaySurfaces = []string{productconfig.ConnectionMultica} diff --git a/harness/internal/mnemonhub/exchange/backend/github/backend.go b/harness/internal/mnemonhub/exchange/backend/github/backend.go deleted file mode 100644 index e706aecc..00000000 --- a/harness/internal/mnemonhub/exchange/backend/github/backend.go +++ /dev/null @@ -1,232 +0,0 @@ -package githubbackend - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/mnemon-dev/mnemon/harness/internal/contract" - eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" -) - -type Config struct { - Store exchange.PublicationStore - Repo string - Branch string - Scopes []contract.ResourceRef -} - -type Backend struct { - store exchange.PublicationStore - repo string - branch string - scopes []contract.ResourceRef -} - -func New(cfg Config) (*Backend, error) { - if cfg.Store == nil { - return nil, fmt.Errorf("publication store is required") - } - repo := strings.TrimSpace(cfg.Repo) - if repo == "" { - return nil, fmt.Errorf("publication repo is required") - } - branch, err := exchange.NormalizePublicationBranch(cfg.Branch) - if err != nil { - return nil, err - } - return &Backend{ - store: cfg.Store, - repo: repo, - branch: branch, - scopes: append([]contract.ResourceRef(nil), cfg.Scopes...), - }, nil -} - -func (b *Backend) SyncPush(req contract.SyncPushRequest) (contract.SyncPushResponse, error) { - replicaID := strings.TrimSpace(req.ReplicaID) - if replicaID == "" { - return contract.SyncPushResponse{}, fmt.Errorf("sync push requires replica_id") - } - var resp contract.SyncPushResponse - for _, env := range req.Events { - material, diagnostic := b.syncedEventMaterial(env) - if diagnostic != "" { - resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", diagnostic)) - continue - } - if material.OriginReplicaID != replicaID { - return contract.SyncPushResponse{}, fmt.Errorf("sync push replica_id %q does not match event origin %q", replicaID, material.OriginReplicaID) - } - if diagnostic := validateSyncedMaterial(material); diagnostic != "" { - resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", diagnostic)) - continue - } - if diagnostic := b.scopeDiagnostic(material.ResourceRef); diagnostic != "" { - resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", diagnostic)) - continue - } - path, err := exchange.PublicationEventPath(env) - if err != nil { - resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", err.Error())) - continue - } - body, err := json.Marshal(env) - if err != nil { - return contract.SyncPushResponse{}, err - } - put, err := b.store.PutEvent(context.Background(), b.branch, path, body) - if err != nil { - return contract.SyncPushResponse{}, err - } - switch { - case put.Created, put.ExistsSame: - resp.Accepted = append(resp.Accepted, eventExchangeResult(env, "accepted", "")) - case put.Conflict: - resp.Conflicts = append(resp.Conflicts, eventExchangeResult(env, "conflict", "publication event path already exists with different content")) - default: - resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", "publication store returned no put verdict")) - } - } - return resp, nil -} - -func (b *Backend) SyncPull(req contract.SyncPullRequest) (contract.SyncPullResponse, error) { - replicaID := strings.TrimSpace(req.ReplicaID) - if replicaID == "" { - return contract.SyncPullResponse{}, fmt.Errorf("sync pull requires replica_id") - } - scopes := append([]contract.ResourceRef(nil), req.Scopes...) - if len(b.scopes) > 0 { - var err error - scopes, err = contract.ClampRefs(contract.ActorID("github-publication"), b.scopes, req.Scopes) - if err != nil { - return contract.SyncPullResponse{}, fmt.Errorf("sync scope: %w", err) - } - } - list, err := b.store.ListEvents(context.Background(), b.branch, exchange.PublicationEventRoot, req.RemoteCursor) - if err != nil { - return contract.SyncPullResponse{}, err - } - resp := contract.SyncPullResponse{NextCursor: localStateCursor(req.RemoteCursor, list.NextCursor)} - for _, stored := range list.Events { - env, result, ok := decodeStoredEvent(stored) - if !ok { - resp.Diagnostics = append(resp.Diagnostics, result) - continue - } - material, diagnostic := b.syncedEventMaterial(env) - if diagnostic != "" { - resp.Diagnostics = append(resp.Diagnostics, eventExchangeResult(env, "invalid", diagnostic)) - continue - } - if material.OriginReplicaID == replicaID { - continue - } - if diagnostic := validateSyncedMaterial(material); diagnostic != "" { - resp.Diagnostics = append(resp.Diagnostics, eventExchangeResult(env, "invalid", diagnostic)) - continue - } - if len(scopes) > 0 && !refAllowed(scopes, material.ResourceRef) { - resp.Diagnostics = append(resp.Diagnostics, eventExchangeResult(env, "rejected", fmt.Sprintf("ref %s/%s is outside configured publication scope", material.ResourceRef.Kind, material.ResourceRef.ID))) - continue - } - resp.Events = append(resp.Events, env) - } - return resp, nil -} - -func localStateCursor(previous, storeCursor string) string { - storeCursor = strings.TrimSpace(storeCursor) - if storeCursor == "" { - return "" - } - if _, err := strconv.ParseInt(storeCursor, 10, 64); err == nil { - return storeCursor - } - previous = strings.TrimSpace(previous) - if _, err := strconv.ParseInt(previous, 10, 64); err == nil && previous != "" { - return previous - } - return "1" -} - -func (b *Backend) SyncStatus() (contract.SyncStatusResponse, error) { - return contract.SyncStatusResponse{RemoteWorkspace: b.repo + ":" + b.branch}, nil -} - -func (b *Backend) syncedEventMaterial(env eventmodel.EventEnvelope) (contract.SyncedEventMaterial, string) { - material, err := contract.SyncedEventMaterialFromEnvelope(env) - if err != nil { - return contract.SyncedEventMaterial{}, err.Error() - } - return material, "" -} - -func (b *Backend) scopeDiagnostic(ref contract.ResourceRef) string { - if len(b.scopes) == 0 || refAllowed(b.scopes, ref) { - return "" - } - return fmt.Sprintf("ref %s/%s is outside configured publication scope", ref.Kind, ref.ID) -} - -func validateSyncedMaterial(material contract.SyncedEventMaterial) string { - switch { - case strings.TrimSpace(material.OriginReplicaID) == "": - return "origin_replica_id is required" - case strings.TrimSpace(material.LocalDecisionID) == "": - return "local_decision_id is required" - case strings.TrimSpace(string(material.Actor)) == "": - return "actor is required" - case strings.TrimSpace(string(material.ResourceRef.Kind)) == "" || strings.TrimSpace(string(material.ResourceRef.ID)) == "": - return "resource_ref is required" - case material.Fields == nil: - return "fields are required" - case strings.TrimSpace(material.FieldsDigest) == "": - return "fields_digest is required" - case material.FieldsDigest != syncedFieldsDigest(material.Fields): - return "fields_digest does not match fields" - default: - return "" - } -} - -func decodeStoredEvent(stored exchange.PublicationStoredEvent) (eventmodel.EventEnvelope, contract.EventExchangeResult, bool) { - var env eventmodel.EventEnvelope - if err := json.Unmarshal(stored.Body, &env); err != nil { - return eventmodel.EventEnvelope{}, contract.EventExchangeResult{ - EventID: stored.Path, - Status: "invalid", - Diagnostic: "publication event json is invalid: " + err.Error(), - }, false - } - return env, contract.EventExchangeResult{}, true -} - -func eventExchangeResult(env eventmodel.EventEnvelope, status, diagnostic string) contract.EventExchangeResult { - result := contract.EventExchangeResultFromEnvelope(env, status, diagnostic) - if strings.TrimSpace(result.EventID) == "" { - result.EventID = env.Event.ID - } - return result -} - -func refAllowed(scopes []contract.ResourceRef, ref contract.ResourceRef) bool { - for _, scope := range scopes { - if scope == ref { - return true - } - } - return false -} - -func syncedFieldsDigest(fields map[string]any) string { - b, _ := json.Marshal(fields) - sum := sha256.Sum256(b) - return hex.EncodeToString(sum[:]) -} diff --git a/harness/internal/mnemonhub/exchange/backend/github/backend_test.go b/harness/internal/mnemonhub/exchange/backend/github/backend_test.go deleted file mode 100644 index 2dc2e7ca..00000000 --- a/harness/internal/mnemonhub/exchange/backend/github/backend_test.go +++ /dev/null @@ -1,246 +0,0 @@ -package githubbackend - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/mnemon-dev/mnemon/harness/internal/contract" - eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" -) - -var progressRef = contract.ResourceRef{Kind: "progress_digest", ID: "project"} - -func TestGitHubBackendFakePushPublishesSyncedEnvelope(t *testing.T) { - store, backend := newFakeBackend(t, "mnemon/mnemond-a", progressRef) - env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "published progress"}) - - resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}) - if err != nil { - t.Fatalf("sync push: %v", err) - } - if len(resp.Accepted) != 1 || len(resp.Rejected) != 0 || len(resp.Conflicts) != 0 { - t.Fatalf("push resp = %+v, want one accepted", resp) - } - list, err := store.ListEvents(context.Background(), "mnemon/mnemond-a", exchange.PublicationEventRoot, "") - if err != nil { - t.Fatalf("list publication events: %v", err) - } - if len(list.Events) != 1 { - t.Fatalf("publication store events = %+v, want one", list.Events) - } - - replayed, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}) - if err != nil { - t.Fatalf("repeat sync push: %v", err) - } - if len(replayed.Accepted) != 1 || len(replayed.Conflicts) != 0 { - t.Fatalf("repeat push must be idempotent accepted, got %+v", replayed) - } -} - -func TestGitHubBackendFakePushRejectsInvalidPhase(t *testing.T) { - _, backend := newFakeBackend(t, "mnemon/mnemond-a", progressRef) - env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "bad phase"}) - env.Phase = eventmodel.PhaseAccepted - - resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}) - if err != nil { - t.Fatalf("sync push invalid phase: %v", err) - } - if len(resp.Rejected) != 1 || !strings.Contains(resp.Rejected[0].Diagnostic, "phase") { - t.Fatalf("invalid phase must be rejected with diagnostic, got %+v", resp) - } -} - -func TestGitHubBackendFakePushDetectsSameKeyDifferentBody(t *testing.T) { - _, backend := newFakeBackend(t, "mnemon/mnemond-a", progressRef) - env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "same key"}) - if resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}); err != nil || len(resp.Accepted) != 1 { - t.Fatalf("seed push: resp=%+v err=%v", resp, err) - } - changedBody := env - changedBody.Event.CorrelationID = "changed-body" - resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-b", Events: []eventmodel.EventEnvelope{changedBody}}) - if err != nil { - t.Fatalf("conflict push: %v", err) - } - if len(resp.Conflicts) != 1 || !strings.Contains(resp.Conflicts[0].Diagnostic, "different content") { - t.Fatalf("same key different body must conflict, got %+v", resp) - } -} - -func TestGitHubBackendFakePullReturnsValidEventsAndSkipsOwnOrigin(t *testing.T) { - store, backend := newFakeBackend(t, "mnemon/mnemond-b", progressRef) - foreign := githubBackendTestEnvelope(t, "replica-b", "dec-foreign", progressRef, map[string]any{"content": "foreign progress"}) - own := githubBackendTestEnvelope(t, "replica-a", "dec-own", progressRef, map[string]any{"content": "own progress"}) - putStoredEvent(t, store, "mnemon/mnemond-b", foreign) - putStoredEvent(t, store, "mnemon/mnemond-b", own) - - resp, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a"}) - if err != nil { - t.Fatalf("sync pull: %v", err) - } - if len(resp.Events) != 1 || resp.Events[0].Event.ID != foreign.Event.ID || len(resp.Diagnostics) != 0 || resp.NextCursor != "2" { - t.Fatalf("pull resp = %+v, want only foreign event and cursor 2", resp) - } - again, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a", RemoteCursor: resp.NextCursor}) - if err != nil { - t.Fatalf("sync pull after cursor: %v", err) - } - if len(again.Events) != 0 || len(again.Diagnostics) != 0 || again.NextCursor != "2" { - t.Fatalf("cursor pull must be empty at cursor 2, got %+v", again) - } -} - -func TestGitHubBackendFakePullReturnsDiagnostics(t *testing.T) { - store, backend := newFakeBackend(t, "mnemon/mnemond-b", progressRef) - invalidPhase := githubBackendTestEnvelope(t, "replica-b", "dec-invalid-phase", progressRef, map[string]any{"content": "invalid phase"}) - invalidPhase.Phase = eventmodel.PhaseAccepted - badDigest := githubBackendTestEnvelope(t, "replica-b", "dec-bad-digest", progressRef, map[string]any{"content": "bad digest"}) - badDigest.Meta["digest"] = "wrong" - outOfScope := githubBackendTestEnvelope(t, "replica-b", "dec-out-scope", contract.ResourceRef{Kind: "assignment", ID: "project"}, map[string]any{"content": "assignment"}) - putStoredEvent(t, store, "mnemon/mnemond-b", invalidPhase) - putStoredEvent(t, store, "mnemon/mnemond-b", badDigest) - putStoredEvent(t, store, "mnemon/mnemond-b", outOfScope) - - resp, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a"}) - if err != nil { - t.Fatalf("sync pull diagnostics: %v", err) - } - if len(resp.Events) != 0 || len(resp.Diagnostics) != 3 { - t.Fatalf("pull diagnostics resp = %+v, want three diagnostics", resp) - } - joined := diagnosticsText(resp.Diagnostics) - for _, want := range []string{"phase", "fields_digest", "outside configured publication scope"} { - if !strings.Contains(joined, want) { - t.Fatalf("diagnostics missing %q in %s", want, joined) - } - } -} - -func TestGitHubBackendPullNormalizesOpaquePublicationCursor(t *testing.T) { - env := githubBackendTestEnvelope(t, "replica-b", "dec-foreign", progressRef, map[string]any{"content": "foreign progress"}) - body, err := json.Marshal(env) - if err != nil { - t.Fatal(err) - } - store := opaqueCursorStore{ - events: []exchange.PublicationStoredEvent{{ - Path: "events/replica-b/foreign.json", - Body: body, - Cursor: "head-abc", - }}, - } - backend, err := New(Config{Store: store, Repo: "mnemon-dev/mnemon-teamwork-example", Branch: "mnemon/mnemond-b", Scopes: []contract.ResourceRef{progressRef}}) - if err != nil { - t.Fatal(err) - } - - resp, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a"}) - if err != nil { - t.Fatalf("sync pull: %v", err) - } - if resp.NextCursor != "1" { - t.Fatalf("opaque publication cursor must normalize to local numeric cursor, got %q", resp.NextCursor) - } - if len(resp.Events) != 1 || resp.Events[0].Event.ID != env.Event.ID { - t.Fatalf("sync pull events = %+v, want foreign event", resp.Events) - } - - again, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a", RemoteCursor: resp.NextCursor}) - if err != nil { - t.Fatalf("sync pull with local cursor: %v", err) - } - if again.NextCursor != resp.NextCursor || len(again.Events) != 1 { - t.Fatalf("opaque cursor pull should keep local cursor and rely on import idempotency, got %+v", again) - } -} - -func newFakeBackend(t *testing.T, branch string, scopes ...contract.ResourceRef) (*exchange.MemoryPublicationStore, *Backend) { - t.Helper() - store, err := exchange.NewMemoryPublicationStore(branch) - if err != nil { - t.Fatal(err) - } - backend, err := New(Config{Store: store, Repo: "mnemon-dev/mnemon-teamwork-example", Branch: branch, Scopes: scopes}) - if err != nil { - t.Fatal(err) - } - return store, backend -} - -type opaqueCursorStore struct { - events []exchange.PublicationStoredEvent -} - -func (s opaqueCursorStore) PutEvent(context.Context, string, string, []byte) (exchange.PublicationPutResult, error) { - return exchange.PublicationPutResult{}, nil -} - -func (s opaqueCursorStore) ListEvents(context.Context, string, string, string) (exchange.PublicationListResult, error) { - return exchange.PublicationListResult{Events: append([]exchange.PublicationStoredEvent(nil), s.events...), NextCursor: "head-abc"}, nil -} - -func (s opaqueCursorStore) ReadFile(context.Context, string, string) ([]byte, error) { - return nil, nil -} - -func (s opaqueCursorStore) WriteFile(context.Context, string, string, []byte) error { - return nil -} - -func putStoredEvent(t *testing.T, store *exchange.MemoryPublicationStore, branch string, env eventmodel.EventEnvelope) { - t.Helper() - path, err := exchange.PublicationEventPath(githubBackendPathEnvelope(env)) - if err != nil { - t.Fatalf("publication path: %v", err) - } - body, err := json.Marshal(env) - if err != nil { - t.Fatal(err) - } - if _, err := store.PutEvent(context.Background(), branch, path, body); err != nil { - t.Fatalf("put stored event: %v", err) - } -} - -func githubBackendPathEnvelope(env eventmodel.EventEnvelope) eventmodel.EventEnvelope { - if env.Phase == eventmodel.PhaseSynced { - return env - } - out := env - out.Phase = eventmodel.PhaseSynced - return out -} - -func githubBackendTestEnvelope(t *testing.T, origin, decisionID string, ref contract.ResourceRef, fields map[string]any) eventmodel.EventEnvelope { - t.Helper() - env, err := contract.SyncedEventEnvelopeFromMaterial(contract.SyncedEventMaterial{ - OriginReplicaID: origin, - LocalDecisionID: decisionID, - LocalIngestSeq: 7, - Actor: "codex@project", - ResourceRef: ref, - ResourceVersion: 1, - FieldsDigest: syncedFieldsDigest(fields), - Fields: fields, - DecidedAt: "2026-06-12T00:00:00Z", - Status: "pending", - }) - if err != nil { - t.Fatalf("synced event envelope: %v", err) - } - return env -} - -func diagnosticsText(items []contract.EventExchangeResult) string { - var b strings.Builder - for _, item := range items { - b.WriteString(item.Diagnostic) - b.WriteByte('\n') - } - return b.String() -} diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store.go deleted file mode 100644 index 58b97c28..00000000 --- a/harness/internal/mnemonhub/exchange/backend/github/publication_store.go +++ /dev/null @@ -1,616 +0,0 @@ -package githubbackend - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - pathpkg "path" - "sort" - "strings" - "sync" - "time" - - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" -) - -const githubAPIVersion = "2022-11-28" - -type PublicationStoreConfig struct { - Repo string - Token string - BaseURL string - UserAgent string - HTTPClient *http.Client - MutativeDelay time.Duration -} - -type GitHubPublicationStore struct { - owner string - repo string - token string - baseURL string - userAgent string - client *http.Client - mutativeDelay time.Duration - writeMu sync.Mutex - lastWrite time.Time -} - -func NewPublicationStore(cfg PublicationStoreConfig) (*GitHubPublicationStore, error) { - repo, err := exchange.NormalizeGitHubRepo(cfg.Repo) - if err != nil { - return nil, err - } - owner, name, _ := strings.Cut(repo, "/") - baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") - if baseURL == "" { - baseURL = "https://api.github.com" - } - client := cfg.HTTPClient - if client == nil { - client = &http.Client{Timeout: 30 * time.Second} - } - userAgent := strings.TrimSpace(cfg.UserAgent) - if userAgent == "" { - userAgent = "mnemon-harness" - } - return &GitHubPublicationStore{ - owner: owner, - repo: name, - token: strings.TrimSpace(cfg.Token), - baseURL: baseURL, - userAgent: userAgent, - client: client, - mutativeDelay: cfg.MutativeDelay, - }, nil -} - -func (s *GitHubPublicationStore) PutEvent(ctx context.Context, branch string, path string, body []byte) (exchange.PublicationPutResult, error) { - branch, path, err := normalizeGitHubPublicationEventRef(branch, path) - if err != nil { - return exchange.PublicationPutResult{}, err - } - s.writeMu.Lock() - defer s.writeMu.Unlock() - existing, found, err := s.readFileWithSHA(ctx, branch, path) - if err != nil { - return exchange.PublicationPutResult{}, err - } - if found { - if bytes.Equal(existing.body, body) { - return exchange.PublicationPutResult{ExistsSame: true}, nil - } - return exchange.PublicationPutResult{Conflict: true}, nil - } - if err := s.pauseBeforeMutation(ctx); err != nil { - return exchange.PublicationPutResult{}, err - } - if err := s.putFile(ctx, branch, path, body, ""); err != nil { - if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusConflict { - return exchange.PublicationPutResult{Conflict: true}, nil - } - return exchange.PublicationPutResult{}, err - } - s.lastWrite = time.Now() - return exchange.PublicationPutResult{Created: true}, nil -} - -func (s *GitHubPublicationStore) ListEvents(ctx context.Context, branch string, prefix string, cursor string) (exchange.PublicationListResult, error) { - branch, err := exchange.NormalizePublicationBranch(branch) - if err != nil { - return exchange.PublicationListResult{}, err - } - prefix, err = normalizeGitHubPublicationEventPrefix(prefix) - if err != nil { - return exchange.PublicationListResult{}, err - } - head, err := s.branchHead(ctx, branch) - if err != nil { - return exchange.PublicationListResult{}, err - } - if strings.TrimSpace(cursor) == head { - return exchange.PublicationListResult{NextCursor: head}, nil - } - paths, err := s.listChangedEventPaths(ctx, head, prefix, cursor) - if err != nil { - return exchange.PublicationListResult{}, err - } - events := make([]exchange.PublicationStoredEvent, 0, len(paths)) - for _, path := range paths { - file, found, err := s.readFileWithSHA(ctx, head, path) - if err != nil { - return exchange.PublicationListResult{}, err - } - if !found { - continue - } - events = append(events, exchange.PublicationStoredEvent{Path: path, Body: file.body, Cursor: head}) - } - return exchange.PublicationListResult{Events: events, NextCursor: head}, nil -} - -func (s *GitHubPublicationStore) ReadFile(ctx context.Context, branch string, path string) ([]byte, error) { - branch, path, err := normalizeGitHubPublicationFileRef(branch, path) - if err != nil { - return nil, err - } - file, found, err := s.readFileWithSHA(ctx, branch, path) - if err != nil { - return nil, err - } - if !found { - return nil, fmt.Errorf("publication file %s:%s not found", branch, path) - } - return append([]byte(nil), file.body...), nil -} - -func (s *GitHubPublicationStore) WriteFile(ctx context.Context, branch string, path string, body []byte) error { - branch, path, err := normalizeGitHubPublicationFileRef(branch, path) - if err != nil { - return err - } - if strings.HasPrefix(path, exchange.PublicationEventRoot+"/") { - return fmt.Errorf("publication event path %q must be written with PutEvent", path) - } - s.writeMu.Lock() - defer s.writeMu.Unlock() - existing, found, err := s.readFileWithSHA(ctx, branch, path) - if err != nil { - return err - } - sha := "" - if found { - sha = existing.sha - } - if err := s.pauseBeforeMutation(ctx); err != nil { - return err - } - if err := s.putFile(ctx, branch, path, body, sha); err != nil { - return err - } - s.lastWrite = time.Now() - return nil -} - -func (s *GitHubPublicationStore) EnsureBranch(ctx context.Context, branch string, baseBranch string) error { - return s.EnsureBranches(ctx, []string{branch}, baseBranch) -} - -func (s *GitHubPublicationStore) EnsureBranches(ctx context.Context, branches []string, baseBranch string) error { - baseBranch, err := normalizeGitHubBranchName(baseBranch) - if err != nil { - return fmt.Errorf("base branch: %w", err) - } - if baseBranch == "" { - baseBranch = "main" - } - normalized := make([]string, 0, len(branches)) - seen := map[string]bool{} - for _, branch := range branches { - branch, err := exchange.NormalizePublicationBranch(branch) - if err != nil { - return err - } - if seen[branch] { - continue - } - seen[branch] = true - normalized = append(normalized, branch) - } - if len(normalized) == 0 { - return nil - } - var missing []string - for _, branch := range normalized { - if _, err := s.branchHead(ctx, branch); err == nil { - continue - } else if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusNotFound { - missing = append(missing, branch) - continue - } else { - return err - } - } - if len(missing) == 0 { - return nil - } - baseSHA, err := s.branchHead(ctx, baseBranch) - if err != nil { - return fmt.Errorf("read base branch %q: %w", baseBranch, err) - } - s.writeMu.Lock() - defer s.writeMu.Unlock() - for _, branch := range missing { - if err := s.pauseBeforeMutation(ctx); err != nil { - return err - } - req := githubCreateRefRequest{ - Ref: "refs/heads/" + branch, - SHA: baseSHA, - } - status, err := s.do(ctx, http.MethodPost, "/repos/"+s.owner+"/"+s.repo+"/git/refs", nil, req, nil) - if err != nil { - if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusUnprocessableEntity { - if _, headErr := s.branchHead(ctx, branch); headErr == nil { - continue - } - } - return fmt.Errorf("create branch %q: %w", branch, err) - } - if status != http.StatusCreated { - return fmt.Errorf("create branch %q returned status %d", branch, status) - } - s.lastWrite = time.Now() - } - return nil -} - -type githubFile struct { - body []byte - sha string -} - -type githubContentResponse struct { - Type string `json:"type"` - Path string `json:"path"` - SHA string `json:"sha"` - Encoding string `json:"encoding"` - Content string `json:"content"` -} - -type githubContentEntry struct { - Type string `json:"type"` - Path string `json:"path"` -} - -type githubCompareResponse struct { - Files []struct { - Filename string `json:"filename"` - Status string `json:"status"` - } `json:"files"` -} - -type githubPutFileRequest struct { - Message string `json:"message"` - Content string `json:"content"` - SHA string `json:"sha,omitempty"` - Branch string `json:"branch"` -} - -type githubCreateRefRequest struct { - Ref string `json:"ref"` - SHA string `json:"sha"` -} - -type githubRefResponse struct { - Object struct { - SHA string `json:"sha"` - } `json:"object"` -} - -type githubAPIError struct { - Status int - Message string - RetryAfter string - RateLimitRemaining string - RateLimitReset string -} - -func (e *githubAPIError) Error() string { - base := "" - if strings.TrimSpace(e.Message) == "" { - base = fmt.Sprintf("github api status %d", e.Status) - } else { - base = fmt.Sprintf("github api status %d: %s", e.Status, e.Message) - } - var hints []string - if strings.TrimSpace(e.RetryAfter) != "" { - hints = append(hints, "retry_after="+strings.TrimSpace(e.RetryAfter)) - } - if strings.TrimSpace(e.RateLimitRemaining) != "" { - hints = append(hints, "rate_limit_remaining="+strings.TrimSpace(e.RateLimitRemaining)) - } - if strings.TrimSpace(e.RateLimitReset) != "" { - hints = append(hints, "rate_limit_reset="+strings.TrimSpace(e.RateLimitReset)) - } - if len(hints) == 0 { - return base - } - return base + " (" + strings.Join(hints, ", ") + ")" -} - -func (s *GitHubPublicationStore) readFileWithSHA(ctx context.Context, branch, path string) (githubFile, bool, error) { - var out githubContentResponse - status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/contents/"+escapeGitHubPath(path), url.Values{"ref": []string{branch}}, nil, &out) - if err != nil { - if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusNotFound { - return githubFile{}, false, nil - } - return githubFile{}, false, err - } - if status != http.StatusOK { - return githubFile{}, false, fmt.Errorf("github content read returned status %d", status) - } - body, err := decodeGitHubContent(out) - if err != nil { - return githubFile{}, false, err - } - return githubFile{body: body, sha: out.SHA}, true, nil -} - -func (s *GitHubPublicationStore) putFile(ctx context.Context, branch, path string, body []byte, sha string) error { - req := githubPutFileRequest{ - Message: "mnemon publication update " + path, - Content: base64.StdEncoding.EncodeToString(body), - SHA: sha, - Branch: branch, - } - status, err := s.do(ctx, http.MethodPut, "/repos/"+s.owner+"/"+s.repo+"/contents/"+escapeGitHubPath(path), nil, req, nil) - if err != nil { - return err - } - if status != http.StatusOK && status != http.StatusCreated { - return fmt.Errorf("github content write returned status %d", status) - } - return nil -} - -func (s *GitHubPublicationStore) branchHead(ctx context.Context, branch string) (string, error) { - var out githubRefResponse - status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/git/ref/heads/"+escapeGitHubPath(branch), nil, nil, &out) - if err != nil { - return "", err - } - if status != http.StatusOK || strings.TrimSpace(out.Object.SHA) == "" { - return "", fmt.Errorf("github branch ref %q returned status %d without sha", branch, status) - } - return strings.TrimSpace(out.Object.SHA), nil -} - -func (s *GitHubPublicationStore) listChangedEventPaths(ctx context.Context, head, prefix, cursor string) ([]string, error) { - cursor = strings.TrimSpace(cursor) - if cursor != "" { - paths, ok, err := s.compareEventPaths(ctx, cursor, head, prefix) - if err != nil { - return nil, err - } - if ok { - return paths, nil - } - } - return s.listEventPaths(ctx, head, prefix) -} - -func (s *GitHubPublicationStore) compareEventPaths(ctx context.Context, base, head, prefix string) ([]string, bool, error) { - if strings.TrimSpace(base) == "" || strings.TrimSpace(head) == "" || base == head { - return nil, true, nil - } - var out githubCompareResponse - status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/compare/"+escapeGitHubPath(base)+"..."+escapeGitHubPath(head), nil, nil, &out) - if err != nil { - if apiErr, ok := err.(*githubAPIError); ok && (apiErr.Status == http.StatusNotFound || apiErr.Status == http.StatusUnprocessableEntity) { - return nil, false, nil - } - return nil, false, err - } - if status != http.StatusOK { - return nil, false, fmt.Errorf("github compare returned status %d", status) - } - seen := map[string]bool{} - var paths []string - for _, file := range out.Files { - path := strings.TrimSpace(file.Filename) - if path == "" || !strings.HasPrefix(path, prefix) || seen[path] { - continue - } - switch strings.TrimSpace(file.Status) { - case "removed", "deleted": - continue - } - seen[path] = true - paths = append(paths, path) - } - sort.Strings(paths) - return paths, true, nil -} - -func (s *GitHubPublicationStore) listEventPaths(ctx context.Context, branch, prefix string) ([]string, error) { - entries, err := s.listDir(ctx, branch, prefix) - if err != nil { - if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusNotFound { - return nil, nil - } - return nil, err - } - var paths []string - for _, entry := range entries { - switch entry.Type { - case "dir": - nested, err := s.listEventPaths(ctx, branch, entry.Path) - if err != nil { - return nil, err - } - paths = append(paths, nested...) - case "file": - if strings.HasPrefix(entry.Path, prefix) { - paths = append(paths, entry.Path) - } - } - } - sort.Strings(paths) - return paths, nil -} - -func (s *GitHubPublicationStore) listDir(ctx context.Context, branch, path string) ([]githubContentEntry, error) { - var entries []githubContentEntry - status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/contents/"+escapeGitHubPath(path), url.Values{"ref": []string{branch}}, nil, &entries) - if err != nil { - return nil, err - } - if status != http.StatusOK { - return nil, fmt.Errorf("github directory list returned status %d", status) - } - return entries, nil -} - -func (s *GitHubPublicationStore) do(ctx context.Context, method, apiPath string, query url.Values, in any, out any) (int, error) { - var body io.Reader - if in != nil { - data, err := json.Marshal(in) - if err != nil { - return 0, err - } - body = bytes.NewReader(data) - } - u := s.baseURL + apiPath - if len(query) > 0 { - u += "?" + query.Encode() - } - req, err := http.NewRequestWithContext(ctx, method, u, body) - if err != nil { - return 0, err - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", githubAPIVersion) - req.Header.Set("User-Agent", s.userAgent) - if in != nil { - req.Header.Set("Content-Type", "application/json") - } - if s.token != "" { - req.Header.Set("Authorization", "Bearer "+s.token) - } - resp, err := s.client.Do(req) - if err != nil { - return 0, err - } - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - return resp.StatusCode, err - } - if resp.StatusCode >= 300 { - var apiErr struct { - Message string `json:"message"` - } - _ = json.Unmarshal(data, &apiErr) - return resp.StatusCode, &githubAPIError{ - Status: resp.StatusCode, - Message: apiErr.Message, - RetryAfter: resp.Header.Get("Retry-After"), - RateLimitRemaining: resp.Header.Get("X-RateLimit-Remaining"), - RateLimitReset: resp.Header.Get("X-RateLimit-Reset"), - } - } - if out != nil && len(data) > 0 { - if err := json.Unmarshal(data, out); err != nil { - return resp.StatusCode, err - } - } - return resp.StatusCode, nil -} - -func (s *GitHubPublicationStore) pauseBeforeMutation(ctx context.Context) error { - if s.mutativeDelay <= 0 || s.lastWrite.IsZero() { - return nil - } - wait := s.mutativeDelay - time.Since(s.lastWrite) - if wait <= 0 { - return nil - } - timer := time.NewTimer(wait) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} - -func decodeGitHubContent(resp githubContentResponse) ([]byte, error) { - if resp.Type != "" && resp.Type != "file" { - return nil, fmt.Errorf("github content %q is not a file", resp.Path) - } - if resp.Encoding != "base64" { - return nil, fmt.Errorf("github content %q has unsupported encoding %q", resp.Path, resp.Encoding) - } - content := strings.ReplaceAll(resp.Content, "\n", "") - body, err := base64.StdEncoding.DecodeString(content) - if err != nil { - return nil, err - } - return body, nil -} - -func normalizeGitHubPublicationEventRef(branch, path string) (string, string, error) { - branch, path, err := normalizeGitHubPublicationFileRef(branch, path) - if err != nil { - return "", "", err - } - if !strings.HasPrefix(path, exchange.PublicationEventRoot+"/") { - return "", "", fmt.Errorf("publication event path %q must be under %s", path, exchange.PublicationEventRoot) - } - return branch, path, nil -} - -func normalizeGitHubPublicationFileRef(branch, path string) (string, string, error) { - branch, err := exchange.NormalizePublicationBranch(branch) - if err != nil { - return "", "", err - } - path, err = exchange.NormalizePublicationPath(path) - if err != nil { - return "", "", err - } - return branch, path, nil -} - -func normalizeGitHubBranchName(branch string) (string, error) { - branch = strings.TrimSpace(branch) - if branch == "" { - return "", nil - } - if strings.Contains(branch, "\\") || strings.HasPrefix(branch, "/") { - return "", fmt.Errorf("branch %q is invalid", branch) - } - for _, part := range strings.Split(branch, "/") { - if part == "" || part == "." || part == ".." { - return "", fmt.Errorf("branch %q is invalid", branch) - } - } - if pathpkg.Clean(branch) != branch { - return "", fmt.Errorf("branch %q is invalid", branch) - } - return branch, nil -} - -func normalizeGitHubPublicationEventPrefix(prefix string) (string, error) { - prefix, err := exchange.NormalizePublicationPath(prefix) - if err != nil { - return "", err - } - if prefix == exchange.PublicationEventRoot { - return prefix, nil - } - if !strings.HasPrefix(prefix, exchange.PublicationEventRoot+"/") { - return "", fmt.Errorf("publication event prefix %q must be under %s", prefix, exchange.PublicationEventRoot) - } - return prefix, nil -} - -func escapeGitHubPath(path string) string { - clean := pathpkg.Clean(strings.Trim(path, "/")) - if clean == "." { - return "" - } - parts := strings.Split(clean, "/") - for i, part := range parts { - parts[i] = url.PathEscape(part) - } - return strings.Join(parts, "/") -} diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go deleted file mode 100644 index 675f8622..00000000 --- a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go +++ /dev/null @@ -1,489 +0,0 @@ -package githubbackend - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" -) - -func TestGitHubPublicationStorePutEventCreateAndIdempotent(t *testing.T) { - fake := newFakeGitHubPublicationAPI(t) - store, err := NewPublicationStore(PublicationStoreConfig{ - Repo: "mnemon-dev/mnemon-teamwork-example", - Token: "secret-token", - BaseURL: fake.server.URL, - HTTPClient: fake.server.Client(), - }) - if err != nil { - t.Fatal(err) - } - path := exchange.PublicationEventRoot + "/replica-a/progress_digest/project/000000000001-dec-a.json" - - first, err := store.PutEvent(context.Background(), "mnemon/mnemond-a", path, []byte(`{"id":"a"}`)) - if err != nil { - t.Fatalf("put event create: %v", err) - } - if !first.Created || fake.puts != 1 { - t.Fatalf("first put = %+v, puts=%d; want created with one PUT", first, fake.puts) - } - same, err := store.PutEvent(context.Background(), "mnemon/mnemond-a", path, []byte(`{"id":"a"}`)) - if err != nil { - t.Fatalf("put event same: %v", err) - } - if !same.ExistsSame || same.Conflict || fake.puts != 1 { - t.Fatalf("same put = %+v puts=%d; want idempotent without PUT", same, fake.puts) - } - conflict, err := store.PutEvent(context.Background(), "mnemon/mnemond-a", path, []byte(`{"id":"b"}`)) - if err != nil { - t.Fatalf("put event conflict: %v", err) - } - if !conflict.Conflict || fake.puts != 1 { - t.Fatalf("conflict put = %+v puts=%d; want conflict without overwrite", conflict, fake.puts) - } -} - -func TestGitHubPublicationStoreWriteFileUpdatesWithSHA(t *testing.T) { - fake := newFakeGitHubPublicationAPI(t) - fake.files["mnemon/team:.mnemon/team.json"] = fakeGitHubFile{body: []byte(`{"schema_version":1}`), sha: "sha-existing"} - store, err := NewPublicationStore(PublicationStoreConfig{ - Repo: "mnemon-dev/mnemon-teamwork-example", - BaseURL: fake.server.URL, - HTTPClient: fake.server.Client(), - }) - if err != nil { - t.Fatal(err) - } - - if err := store.WriteFile(context.Background(), "mnemon/team", ".mnemon/team.json", []byte(`{"schema_version":2}`)); err != nil { - t.Fatalf("write team manifest: %v", err) - } - if fake.lastSHA != "sha-existing" { - t.Fatalf("write file must include existing sha, got %q", fake.lastSHA) - } - body, err := store.ReadFile(context.Background(), "mnemon/team", ".mnemon/team.json") - if err != nil { - t.Fatalf("read team manifest: %v", err) - } - if string(body) != `{"schema_version":2}` { - t.Fatalf("read body = %s", body) - } -} - -func TestGitHubPublicationStoreListEventsUsesBranchHeadCursor(t *testing.T) { - fake := newFakeGitHubPublicationAPI(t) - fake.head = "head-2" - fake.files["head-2:"+exchange.PublicationEventRoot+"/replica-b/progress_digest/project/000000000001-dec-b.json"] = fakeGitHubFile{body: []byte(`{"id":"b"}`), sha: "sha-b"} - fake.files["head-2:"+exchange.PublicationEventRoot+"/replica-c/progress_digest/project/000000000001-dec-c.json"] = fakeGitHubFile{body: []byte(`{"id":"c"}`), sha: "sha-c"} - store, err := NewPublicationStore(PublicationStoreConfig{ - Repo: "mnemon-dev/mnemon-teamwork-example", - BaseURL: fake.server.URL, - HTTPClient: fake.server.Client(), - }) - if err != nil { - t.Fatal(err) - } - - list, err := store.ListEvents(context.Background(), "mnemon/mnemond-b", exchange.PublicationEventRoot, "") - if err != nil { - t.Fatalf("list events: %v", err) - } - if len(list.Events) != 2 || list.NextCursor != "head-2" { - t.Fatalf("list = %+v, want two events at head-2", list) - } - if !stringSliceContains(fake.contentRefs, "head-2") { - t.Fatalf("list events must read contents at branch head sha, refs=%v", fake.contentRefs) - } - again, err := store.ListEvents(context.Background(), "mnemon/mnemond-b", exchange.PublicationEventRoot, list.NextCursor) - if err != nil { - t.Fatalf("list after head cursor: %v", err) - } - if len(again.Events) != 0 || again.NextCursor != "head-2" { - t.Fatalf("list after head cursor = %+v, want empty", again) - } -} - -func TestGitHubPublicationStoreListEventsUsesCompareAfterCursor(t *testing.T) { - fake := newFakeGitHubPublicationAPI(t) - fake.head = "head-2" - oldPath := exchange.PublicationEventRoot + "/replica-b/progress_digest/project/000000000001-dec-b.json" - newPath := exchange.PublicationEventRoot + "/replica-b/progress_digest/project/000000000002-dec-b.json" - fake.files["head-1:"+oldPath] = fakeGitHubFile{body: []byte(`{"id":"old"}`), sha: "sha-old"} - fake.files["head-2:"+oldPath] = fakeGitHubFile{body: []byte(`{"id":"old"}`), sha: "sha-old"} - fake.files["head-2:"+newPath] = fakeGitHubFile{body: []byte(`{"id":"new"}`), sha: "sha-new"} - fake.files["head-2:README.md"] = fakeGitHubFile{body: []byte(`# ignored`), sha: "sha-readme"} - store, err := NewPublicationStore(PublicationStoreConfig{ - Repo: "mnemon-dev/mnemon-teamwork-example", - BaseURL: fake.server.URL, - HTTPClient: fake.server.Client(), - }) - if err != nil { - t.Fatal(err) - } - - list, err := store.ListEvents(context.Background(), "mnemon/mnemond-b", exchange.PublicationEventRoot, "head-1") - if err != nil { - t.Fatalf("list events after cursor: %v", err) - } - if len(list.Events) != 1 || list.Events[0].Path != newPath || string(list.Events[0].Body) != `{"id":"new"}` || list.NextCursor != "head-2" { - t.Fatalf("list after cursor = %+v, want only changed publication event", list) - } - if len(fake.compareRefs) != 1 || fake.compareRefs[0] != "head-1...head-2" { - t.Fatalf("compare refs = %v, want head-1...head-2", fake.compareRefs) - } - if fake.dirLists != 0 { - t.Fatalf("incremental list should not scan directories, dirLists=%d", fake.dirLists) - } -} - -func TestGitHubPublicationStoreEnsureBranchCreatesMissingBranchFromMain(t *testing.T) { - fake := newFakeGitHubPublicationAPI(t) - fake.refs["main"] = "main-sha" - fake.missingRefs["mnemon/mnemond-run-1-a"] = true - store, err := NewPublicationStore(PublicationStoreConfig{ - Repo: "mnemon-dev/mnemon-teamwork-example", - BaseURL: fake.server.URL, - HTTPClient: fake.server.Client(), - }) - if err != nil { - t.Fatal(err) - } - - if err := store.EnsureBranch(context.Background(), "mnemon/mnemond-run-1-a", "main"); err != nil { - t.Fatalf("ensure branch: %v", err) - } - if fake.creates != 1 { - t.Fatalf("creates = %d, want one branch create", fake.creates) - } - if got := fake.refs["mnemon/mnemond-run-1-a"]; got != "main-sha" { - t.Fatalf("created branch sha = %q, want main-sha", got) - } - if err := store.EnsureBranch(context.Background(), "mnemon/mnemond-run-1-a", "main"); err != nil { - t.Fatalf("ensure branch again: %v", err) - } - if fake.creates != 1 { - t.Fatalf("idempotent ensure must not recreate branch, creates=%d", fake.creates) - } -} - -func TestGitHubPublicationStoreEnsureBranchesReadsBaseOnce(t *testing.T) { - fake := newFakeGitHubPublicationAPI(t) - fake.refs["main"] = "main-sha" - fake.missingRefs["mnemon/mnemond-run-a"] = true - fake.missingRefs["mnemon/mnemond-run-b"] = true - store, err := NewPublicationStore(PublicationStoreConfig{ - Repo: "mnemon-dev/mnemon-teamwork-example", - BaseURL: fake.server.URL, - HTTPClient: fake.server.Client(), - }) - if err != nil { - t.Fatal(err) - } - - err = store.EnsureBranches(context.Background(), []string{ - "mnemon/mnemond-run-a", - "mnemon/mnemond-run-b", - "mnemon/mnemond-run-b", - }, "main") - if err != nil { - t.Fatalf("ensure branches: %v", err) - } - if fake.creates != 2 { - t.Fatalf("creates = %d, want two branch creates", fake.creates) - } - if got := fake.refReads["main"]; got != 1 { - t.Fatalf("main ref reads = %d, want one", got) - } - for _, branch := range []string{"mnemon/mnemond-run-a", "mnemon/mnemond-run-b"} { - if got := fake.refs[branch]; got != "main-sha" { - t.Fatalf("created branch %s sha = %q, want main-sha", branch, got) - } - } -} - -func TestGitHubPublicationStoreRateLimitErrorIncludesRetryHints(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Retry-After", "120") - w.Header().Set("X-RateLimit-Remaining", "0") - w.Header().Set("X-RateLimit-Reset", "1782416790") - writeJSON(w, http.StatusForbidden, map[string]any{"message": "API rate limit exceeded"}) - })) - t.Cleanup(server.Close) - store, err := NewPublicationStore(PublicationStoreConfig{ - Repo: "mnemon-dev/mnemon-teamwork-example", - BaseURL: server.URL, - HTTPClient: server.Client(), - }) - if err != nil { - t.Fatal(err) - } - - err = store.EnsureBranch(context.Background(), "mnemon/mnemond-run-a", "main") - if err == nil { - t.Fatal("ensure branch should return rate-limit error") - } - msg := err.Error() - for _, want := range []string{"API rate limit exceeded", "retry_after=120", "rate_limit_remaining=0", "rate_limit_reset=1782416790"} { - if !strings.Contains(msg, want) { - t.Fatalf("error %q does not include %q", msg, want) - } - } -} - -func TestGitHubPublicationStoreLiveGated(t *testing.T) { - if os.Getenv("MNEMON_GITHUB_LIVE") != "1" { - t.Skip("set MNEMON_GITHUB_LIVE=1 to run the real GitHub publication store smoke test") - } - token := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) - if token == "" { - t.Skip("GITHUB_TOKEN is required for live GitHub publication store smoke test") - } - repo := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_REPO")) - if repo == "" { - repo = "mnemon-dev/mnemon-teamwork-example" - } - branch := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH")) - if branch == "" { - branch = "mnemon/mnemond-a" - } - store, err := NewPublicationStore(PublicationStoreConfig{Repo: repo, Token: token}) - if err != nil { - t.Fatal(err) - } - if err := store.EnsureBranch(context.Background(), branch, "main"); err != nil { - t.Fatalf("ensure live branch: %v", err) - } - path := exchange.PublicationEventRoot + "/live-smoke/progress_digest/project/000000000001-live-smoke.json" - body := []byte(`{"schema_version":1,"source":"mnemon live smoke"}`) - res, err := store.PutEvent(context.Background(), branch, path, body) - if err != nil { - t.Fatalf("live put event: %v", err) - } - if !res.Created && !res.ExistsSame { - t.Fatalf("live put result = %+v, want created or exists_same", res) - } - list, err := store.ListEvents(context.Background(), branch, exchange.PublicationEventRoot, "") - if err != nil { - t.Fatalf("live list events: %v", err) - } - if list.NextCursor == "" { - t.Fatalf("live list must return a branch-head cursor: %+v", list) - } -} - -type fakeGitHubPublicationAPI struct { - server *httptest.Server - files map[string]fakeGitHubFile - refs map[string]string - missingRefs map[string]bool - refReads map[string]int - contentRefs []string - compareRefs []string - head string - dirLists int - puts int - creates int - lastSHA string -} - -type fakeGitHubFile struct { - body []byte - sha string -} - -func newFakeGitHubPublicationAPI(t *testing.T) *fakeGitHubPublicationAPI { - t.Helper() - fake := &fakeGitHubPublicationAPI{ - files: map[string]fakeGitHubFile{}, - refs: map[string]string{}, - missingRefs: map[string]bool{}, - refReads: map[string]int{}, - head: "head-1", - } - fake.server = httptest.NewServer(http.HandlerFunc(fake.handle)) - t.Cleanup(fake.server.Close) - return fake -} - -func (f *fakeGitHubPublicationAPI) handle(w http.ResponseWriter, r *http.Request) { - if token := r.Header.Get("Authorization"); strings.Contains(token, "secret-token") && token != "Bearer secret-token" { - http.Error(w, `{"message":"bad token"}`, http.StatusUnauthorized) - return - } - prefix := "/repos/mnemon-dev/mnemon-teamwork-example/" - if !strings.HasPrefix(r.URL.Path, prefix) { - http.NotFound(w, r) - return - } - tail := strings.TrimPrefix(r.URL.Path, prefix) - switch { - case r.Method == http.MethodGet && strings.HasPrefix(tail, "git/ref/heads/"): - branch := strings.TrimPrefix(tail, "git/ref/heads/") - f.refReads[branch]++ - if f.missingRefs[branch] { - writeJSON(w, http.StatusNotFound, map[string]any{"message": "ref not found"}) - return - } - sha := f.refs[branch] - if sha == "" { - sha = f.head - } - writeJSON(w, http.StatusOK, map[string]any{"object": map[string]any{"sha": sha}}) - case r.Method == http.MethodPost && tail == "git/refs": - var req struct { - Ref string `json:"ref"` - SHA string `json:"sha"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"message": err.Error()}) - return - } - branch := strings.TrimPrefix(req.Ref, "refs/heads/") - if branch == req.Ref || branch == "" || req.SHA == "" { - writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "invalid ref"}) - return - } - delete(f.missingRefs, branch) - f.refs[branch] = req.SHA - f.creates++ - writeJSON(w, http.StatusCreated, map[string]any{"ref": req.Ref, "object": map[string]any{"sha": req.SHA}}) - case r.Method == http.MethodGet && strings.HasPrefix(tail, "compare/"): - ref := strings.TrimPrefix(tail, "compare/") - f.compareRefs = append(f.compareRefs, ref) - base, head, ok := strings.Cut(ref, "...") - if !ok || base == "" || head == "" { - writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "invalid compare"}) - return - } - writeJSON(w, http.StatusOK, map[string]any{"files": f.compareFiles(base, head)}) - case strings.HasPrefix(tail, "contents/"): - path := strings.TrimPrefix(tail, "contents/") - branch := r.URL.Query().Get("ref") - f.contentRefs = append(f.contentRefs, branch) - f.handleContents(w, r, branch, path) - default: - http.NotFound(w, r) - } -} - -func stringSliceContains(values []string, want string) bool { - for _, value := range values { - if value == want { - return true - } - } - return false -} - -func (f *fakeGitHubPublicationAPI) handleContents(w http.ResponseWriter, r *http.Request, branch, path string) { - switch r.Method { - case http.MethodGet: - key := branch + ":" + path - if file, ok := f.files[key]; ok { - writeJSON(w, http.StatusOK, map[string]any{ - "type": "file", - "path": path, - "sha": file.sha, - "encoding": "base64", - "content": base64.StdEncoding.EncodeToString(file.body), - }) - return - } - entries := f.dirEntries(branch, path) - if len(entries) > 0 { - f.dirLists++ - writeJSON(w, http.StatusOK, entries) - return - } - writeJSON(w, http.StatusNotFound, map[string]any{"message": "not found"}) - case http.MethodPut: - var req struct { - Content string `json:"content"` - SHA string `json:"sha"` - Branch string `json:"branch"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"message": err.Error()}) - return - } - body, err := base64.StdEncoding.DecodeString(req.Content) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"message": err.Error()}) - return - } - if branch == "" { - branch = req.Branch - } - key := branch + ":" + path - if existing, ok := f.files[key]; ok && req.SHA != existing.sha { - writeJSON(w, http.StatusConflict, map[string]any{"message": "sha mismatch"}) - return - } - f.puts++ - f.lastSHA = req.SHA - f.files[key] = fakeGitHubFile{body: body, sha: "sha-new"} - status := http.StatusCreated - if req.SHA != "" { - status = http.StatusOK - } - writeJSON(w, status, map[string]any{"content": map[string]any{"sha": "sha-new"}}) - default: - http.NotFound(w, r) - } -} - -func (f *fakeGitHubPublicationAPI) compareFiles(base, head string) []map[string]any { - prefix := head + ":" - var out []map[string]any - for key, file := range f.files { - if !strings.HasPrefix(key, prefix) { - continue - } - path := strings.TrimPrefix(key, prefix) - status := "added" - if previous, ok := f.files[base+":"+path]; ok { - if bytes.Equal(previous.body, file.body) { - continue - } - status = "modified" - } - out = append(out, map[string]any{"filename": path, "status": status}) - } - return out -} - -func (f *fakeGitHubPublicationAPI) dirEntries(branch, dir string) []map[string]any { - prefix := branch + ":" + strings.TrimSuffix(dir, "/") + "/" - seen := map[string]map[string]any{} - for key := range f.files { - if !strings.HasPrefix(key, prefix) { - continue - } - rel := strings.TrimPrefix(key, prefix) - head, _, hasRest := strings.Cut(rel, "/") - path := strings.TrimSuffix(dir, "/") + "/" + head - typ := "file" - if hasRest { - typ = "dir" - } - seen[path] = map[string]any{"type": typ, "path": path} - } - out := make([]map[string]any, 0, len(seen)) - for _, entry := range seen { - out = append(out, entry) - } - return out -} - -func writeJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(value) -} diff --git a/harness/internal/mnemonhub/exchange/publication_store.go b/harness/internal/mnemonhub/exchange/publication_store.go index d5959b82..d3c48c17 100644 --- a/harness/internal/mnemonhub/exchange/publication_store.go +++ b/harness/internal/mnemonhub/exchange/publication_store.go @@ -17,8 +17,8 @@ import ( const PublicationEventRoot = "mnemon-publications/v1/events" // PublicationStore is the storage seam under a Remote Workspace publication backend. It is -// intentionally repository-shaped but not tied to any GitHub client, so exchange semantics can be -// tested against a deterministic fake before a real API adapter exists. +// intentionally repository-shaped but not tied to any specific repository host, so exchange +// semantics can be tested against a deterministic fake before a real API adapter exists. type PublicationStore interface { PutEvent(ctx context.Context, branch string, path string, body []byte) (PublicationPutResult, error) ListEvents(ctx context.Context, branch string, prefix string, cursor string) (PublicationListResult, error) diff --git a/harness/internal/mnemonhub/exchange/remote_workspace.go b/harness/internal/mnemonhub/exchange/remote_workspace.go index c4a2e9b5..9449eb0f 100644 --- a/harness/internal/mnemonhub/exchange/remote_workspace.go +++ b/harness/internal/mnemonhub/exchange/remote_workspace.go @@ -6,8 +6,8 @@ import "github.com/mnemon-dev/mnemon/harness/internal/contract" // // The method names intentionally match the existing wire verbs so the current // HTTP mnemon-hub client satisfies this interface without a wrapper. Future -// backends, such as a GitHub publication mesh, must present the same accepted -// synced-envelope behavior to the local sync loop. +// backends must present the same accepted synced-envelope behavior to the local +// sync loop. type RemoteWorkspace interface { SyncPush(contract.SyncPushRequest) (contract.SyncPushResponse, error) SyncPull(contract.SyncPullRequest) (contract.SyncPullResponse, error) diff --git a/harness/internal/mnemonhub/exchange/remotes.go b/harness/internal/mnemonhub/exchange/remotes.go index 1c8d8b12..70dbdf36 100644 --- a/harness/internal/mnemonhub/exchange/remotes.go +++ b/harness/internal/mnemonhub/exchange/remotes.go @@ -18,8 +18,7 @@ type RemotesDoc struct { } const ( - RemoteBackendHTTP = "http" - RemoteBackendGitHub = "github" + RemoteBackendHTTP = "http" ) const ( @@ -37,8 +36,6 @@ type RemoteEntry struct { Direction string `json:"direction,omitempty"` ID string `json:"id"` Endpoint string `json:"endpoint,omitempty"` - Repo string `json:"repo,omitempty"` - Branch string `json:"branch,omitempty"` CredentialRef string `json:"credential_ref"` // CAFile optionally pins the remote's TLS root (PEM bundle) — the client trusts exactly it // (sync-abi-v1 §8). Empty = the system roots. @@ -84,10 +81,10 @@ func NormalizeRemoteDirection(direction string) (string, error) { func validateRemoteBackend(backend string) error { switch backend { - case RemoteBackendHTTP, RemoteBackendGitHub: + case RemoteBackendHTTP: return nil default: - return fmt.Errorf("unsupported Remote Workspace backend %q (supported: %s, %s)", backend, RemoteBackendHTTP, RemoteBackendGitHub) + return fmt.Errorf("unsupported Remote Workspace backend %q (supported: %s)", backend, RemoteBackendHTTP) } } @@ -181,54 +178,12 @@ func normalizeRemoteEntry(remote RemoteEntry) (RemoteEntry, error) { if err != nil { return RemoteEntry{}, err } - switch remote.Backend { - case RemoteBackendHTTP: - if strings.TrimSpace(remote.Endpoint) == "" { - return RemoteEntry{}, fmt.Errorf("has no endpoint") - } - case RemoteBackendGitHub: - remote.Repo, err = NormalizeGitHubRepo(remote.Repo) - if err != nil { - return RemoteEntry{}, err - } - remote.Branch, err = NormalizePublicationBranch(remote.Branch) - if err != nil { - return RemoteEntry{}, err - } + if remote.Backend == RemoteBackendHTTP && strings.TrimSpace(remote.Endpoint) == "" { + return RemoteEntry{}, fmt.Errorf("has no endpoint") } return remote, nil } -func NormalizeGitHubRepo(repo string) (string, error) { - repo = strings.TrimSpace(repo) - if repo == "" { - return "", fmt.Errorf("github repo is required") - } - parts := strings.Split(repo, "/") - if len(parts) != 2 { - return "", fmt.Errorf("github repo %q must be owner/name", repo) - } - for _, part := range parts { - if !validGitHubRepoSegment(part) { - return "", fmt.Errorf("github repo %q is invalid", repo) - } - } - return repo, nil -} - -func validGitHubRepoSegment(segment string) bool { - if segment == "" || segment == "." || segment == ".." { - return false - } - for _, r := range segment { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { - continue - } - return false - } - return true -} - func findRemoteEntry(doc RemotesDoc, id string) (RemoteEntry, bool) { for _, remote := range doc.Remotes { if remote.ID == id { diff --git a/harness/internal/mnemonhub/exchange/remotes_test.go b/harness/internal/mnemonhub/exchange/remotes_test.go index d6c069d1..e5d8b03f 100644 --- a/harness/internal/mnemonhub/exchange/remotes_test.go +++ b/harness/internal/mnemonhub/exchange/remotes_test.go @@ -52,7 +52,7 @@ func TestLoadRemoteEntryRejectsUnsupportedBackend(t *testing.T) { } } -func TestLoadRemoteEntryAcceptsGitHubPublicationConfig(t *testing.T) { +func TestLoadRemoteEntryRejectsGitHubBackend(t *testing.T) { path := filepath.Join(t.TempDir(), "remotes.json") if err := os.WriteFile(path, []byte(`{ "schema_version": 1, @@ -69,60 +69,9 @@ func TestLoadRemoteEntryAcceptsGitHubPublicationConfig(t *testing.T) { t.Fatal(err) } - remote, err := LoadRemoteEntry(path, "default") - if err != nil { - t.Fatalf("load github remote: %v", err) - } - if remote.Backend != RemoteBackendGitHub || remote.Direction != RemoteDirectionPublish || - remote.Repo != "mnemon-dev/mnemon-teamwork-example" || remote.Branch != "mnemon/agent-a" { - t.Fatalf("github remote not normalized: %+v", remote) - } -} - -func TestLoadRemoteEntryRejectsInvalidGitHubPublicationConfig(t *testing.T) { - cases := []struct { - name string - body string - want string - }{{ - name: "missing repo", - body: `{ - "schema_version": 1, - "remotes": [{ - "id": "self", - "backend": "github", - "direction": "publish", - "branch": "mnemon/agent-a", - "credential_ref": ".mnemon/harness/sync/credentials/self.token" - }] - }`, - want: "github repo is required", - }, { - name: "bad branch", - body: `{ - "schema_version": 1, - "remotes": [{ - "id": "self", - "backend": "github", - "direction": "publish", - "repo": "mnemon-dev/mnemon-teamwork-example", - "branch": "main", - "credential_ref": ".mnemon/harness/sync/credentials/self.token" - }] - }`, - want: "outside the mnemon namespace", - }} - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "remotes.json") - if err := os.WriteFile(path, []byte(tc.body+"\n"), 0o600); err != nil { - t.Fatal(err) - } - _, err := LoadRemoteEntry(path, "self") - if err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("invalid github remote must fail with %q, got %v", tc.want, err) - } - }) + _, err := LoadRemoteEntry(path, "default") + if err == nil || !strings.Contains(err.Error(), "unsupported Remote Workspace backend") { + t.Fatalf("github backend must fail closed, got %v", err) } } diff --git a/harness/internal/mnemonhub/sync_abi_fixture_test.go b/harness/internal/mnemonhub/sync_abi_fixture_test.go new file mode 100644 index 00000000..1bd85a9e --- /dev/null +++ b/harness/internal/mnemonhub/sync_abi_fixture_test.go @@ -0,0 +1,278 @@ +package mnemonhub + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" +) + +type syncABIFixture struct { + Name string `json:"name"` + Kind string `json:"kind"` + Principal contract.ActorID `json:"principal"` + ReplicaID string `json:"replica_id"` + GrantScopes []contract.ResourceRef `json:"grant_scopes"` + SeedPrincipal contract.ActorID `json:"seed_principal"` + SeedReplicaID string `json:"seed_replica_id"` + SeedEvents []syncABIEventFixture `json:"seed_events"` + Events []syncABIEventFixture `json:"events"` + Route string `json:"route"` + Method string `json:"method"` + Token string `json:"token"` + Want syncABIWant `json:"want"` +} + +type syncABIEventFixture struct { + OriginReplicaID string `json:"origin_replica_id"` + LocalDecisionID string `json:"local_decision_id"` + ResourceRef contract.ResourceRef `json:"resource_ref"` + Fields map[string]any `json:"fields"` + CorruptDigest bool `json:"corrupt_digest"` +} + +type syncABIWant struct { + Accepted int `json:"accepted"` + Rejected int `json:"rejected"` + Conflicts int `json:"conflicts"` + StoredEvents int `json:"stored_events"` + Events int `json:"events"` + AfterCursorEvents int `json:"after_cursor_events"` + HubEventsReceived int `json:"hub_events_received"` + StatusCode int `json:"status_code"` +} + +func TestSyncABIFixtures(t *testing.T) { + for _, fixture := range loadSyncABIFixtures(t) { + fixture := fixture + t.Run(fixture.Name, func(t *testing.T) { + switch fixture.Kind { + case "push": + runSyncABIPushFixture(t, fixture) + case "push_replay": + runSyncABIReplayFixture(t, fixture) + case "push_conflict": + runSyncABIConflictFixture(t, fixture) + case "pull": + runSyncABIPullFixture(t, fixture) + case "status": + runSyncABIStatusFixture(t, fixture) + case "http_auth": + runSyncABIHTTPAuthFixture(t, fixture) + default: + t.Fatalf("unknown sync ABI fixture kind %q", fixture.Kind) + } + }) + } +} + +func loadSyncABIFixtures(t *testing.T) []syncABIFixture { + t.Helper() + root := filepath.Join("..", "..", "testdata", "mnemonhub", "sync-abi") + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("read sync ABI fixtures: %v", err) + } + var fixtures []syncABIFixture + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + raw, err := os.ReadFile(filepath.Join(root, entry.Name())) + if err != nil { + t.Fatalf("read fixture %s: %v", entry.Name(), err) + } + var fixture syncABIFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatalf("decode fixture %s: %v", entry.Name(), err) + } + if fixture.Name == "" { + t.Fatalf("fixture %s has empty name", entry.Name()) + } + fixtures = append(fixtures, fixture) + } + if len(fixtures) == 0 { + t.Fatal("no sync ABI fixtures loaded") + } + return fixtures +} + +func openFixtureHub(t *testing.T, fixture syncABIFixture) *Server { + t.Helper() + grants := GrantMap{} + if fixture.Principal != "" { + grants[fixture.Principal] = ReplicaGrant{Principal: fixture.Principal, Scopes: fixture.GrantScopes} + } + if fixture.SeedPrincipal != "" { + grants[fixture.SeedPrincipal] = ReplicaGrant{Principal: fixture.SeedPrincipal, Scopes: fixture.GrantScopes} + } + hub, _ := openTestHub(t, grants) + return hub +} + +func runSyncABIPushFixture(t *testing.T, fixture syncABIFixture) { + hub := openFixtureHub(t, fixture) + resp, err := hub.Push(fixture.Principal, contract.SyncPushRequest{ + ReplicaID: fixture.ReplicaID, + BatchID: fixture.Name, + Events: fixtureEvents(t, fixture.Events), + }) + if err != nil { + t.Fatalf("push fixture %s: %v", fixture.Name, err) + } + assertExchangeCounts(t, fixture, resp) +} + +func runSyncABIReplayFixture(t *testing.T, fixture syncABIFixture) { + hub, st := openTestHub(t, GrantMap{ + fixture.Principal: {Principal: fixture.Principal, Scopes: fixture.GrantScopes}, + }) + req := contract.SyncPushRequest{ReplicaID: fixture.ReplicaID, BatchID: fixture.Name, Events: fixtureEvents(t, fixture.Events)} + first, err := hub.Push(fixture.Principal, req) + if err != nil { + t.Fatalf("first replay fixture push: %v", err) + } + second, err := hub.Push(fixture.Principal, req) + if err != nil { + t.Fatalf("second replay fixture push: %v", err) + } + assertExchangeCounts(t, fixture, second) + if len(first.Accepted) != len(second.Accepted) || first.NextCursor != second.NextCursor { + t.Fatalf("replay fixture must return stable accepted ack: first=%+v second=%+v", first, second) + } + if fixture.Want.StoredEvents > 0 { + got, _ := st.RemoteSyncedEventCount() + if int(got) != fixture.Want.StoredEvents { + t.Fatalf("stored events = %d, want %d", got, fixture.Want.StoredEvents) + } + } +} + +func runSyncABIConflictFixture(t *testing.T, fixture syncABIFixture) { + if len(fixture.Events) != 2 { + t.Fatalf("conflict fixture requires exactly two events") + } + hub := openFixtureHub(t, fixture) + first := contract.SyncPushRequest{ReplicaID: fixture.ReplicaID, BatchID: fixture.Name + "-first", Events: fixtureEvents(t, fixture.Events[:1])} + if _, err := hub.Push(fixture.Principal, first); err != nil { + t.Fatalf("seed conflict fixture: %v", err) + } + second := contract.SyncPushRequest{ReplicaID: fixture.ReplicaID, BatchID: fixture.Name + "-second", Events: fixtureEvents(t, fixture.Events[1:])} + resp, err := hub.Push(fixture.Principal, second) + if err != nil { + t.Fatalf("conflict fixture push: %v", err) + } + assertExchangeCounts(t, fixture, resp) +} + +func runSyncABIPullFixture(t *testing.T, fixture syncABIFixture) { + hub := openFixtureHub(t, fixture) + seedFixture(t, hub, fixture) + resp, err := hub.Pull(fixture.Principal, contract.SyncPullRequest{ReplicaID: fixture.ReplicaID}) + if err != nil { + t.Fatalf("pull fixture %s: %v", fixture.Name, err) + } + if len(resp.Events) != fixture.Want.Events { + t.Fatalf("pull events = %d, want %d", len(resp.Events), fixture.Want.Events) + } + if fixture.Want.AfterCursorEvents >= 0 && resp.NextCursor != "" { + after, err := hub.Pull(fixture.Principal, contract.SyncPullRequest{ReplicaID: fixture.ReplicaID, RemoteCursor: resp.NextCursor}) + if err != nil { + t.Fatalf("pull after cursor: %v", err) + } + if len(after.Events) != fixture.Want.AfterCursorEvents { + t.Fatalf("after-cursor events = %d, want %d", len(after.Events), fixture.Want.AfterCursorEvents) + } + } +} + +func runSyncABIStatusFixture(t *testing.T, fixture syncABIFixture) { + hub := openFixtureHub(t, fixture) + seedFixture(t, hub, fixture) + resp, err := hub.Status(fixture.Principal) + if err != nil { + t.Fatalf("status fixture %s: %v", fixture.Name, err) + } + if int(resp.HubEventsReceived) != fixture.Want.HubEventsReceived { + t.Fatalf("HubEventsReceived = %d, want %d", resp.HubEventsReceived, fixture.Want.HubEventsReceived) + } +} + +func runSyncABIHTTPAuthFixture(t *testing.T, fixture syncABIFixture) { + mem := contract.ResourceRef{Kind: "memory", ID: "project"} + hub, _ := openTestHub(t, GrantMap{ + "replica-a@team": {Principal: "replica-a@team", Scopes: []contract.ResourceRef{mem}}, + }) + var audit bytes.Buffer + srv := httptest.NewServer(NewHTTPHandler(hub, BearerAuthenticator{Tokens: map[string]contract.ActorID{ + "tok-a": "replica-a@team", + }}, &audit)) + t.Cleanup(srv.Close) + body := contract.SyncPushRequest{ReplicaID: "local-a", BatchID: fixture.Name, Events: fixtureEvents(t, []syncABIEventFixture{{ + OriginReplicaID: "local-a", + LocalDecisionID: "dec-auth", + ResourceRef: mem, + Fields: map[string]any{"content": "auth"}, + }})} + raw, _ := json.Marshal(body) + req, err := http.NewRequest(fixture.Method, srv.URL+fixture.Route, bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + if fixture.Token != "" { + req.Header.Set("Authorization", "Bearer "+fixture.Token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != fixture.Want.StatusCode { + t.Fatalf("status code = %d, want %d; audit=%s", resp.StatusCode, fixture.Want.StatusCode, audit.String()) + } +} + +func seedFixture(t *testing.T, hub *Server, fixture syncABIFixture) { + t.Helper() + if len(fixture.SeedEvents) == 0 { + return + } + _, err := hub.Push(fixture.SeedPrincipal, contract.SyncPushRequest{ + ReplicaID: fixture.SeedReplicaID, + BatchID: fixture.Name + "-seed", + Events: fixtureEvents(t, fixture.SeedEvents), + }) + if err != nil { + t.Fatalf("seed fixture %s: %v", fixture.Name, err) + } +} + +func assertExchangeCounts(t *testing.T, fixture syncABIFixture, resp contract.SyncPushResponse) { + t.Helper() + if len(resp.Accepted) != fixture.Want.Accepted || len(resp.Rejected) != fixture.Want.Rejected || len(resp.Conflicts) != fixture.Want.Conflicts { + t.Fatalf("%s counts accepted/rejected/conflicts = %d/%d/%d, want %d/%d/%d: %+v", + fixture.Name, + len(resp.Accepted), len(resp.Rejected), len(resp.Conflicts), + fixture.Want.Accepted, fixture.Want.Rejected, fixture.Want.Conflicts, + resp) + } +} + +func fixtureEvents(t *testing.T, fixtures []syncABIEventFixture) []eventmodel.EventEnvelope { + t.Helper() + events := make([]eventmodel.EventEnvelope, 0, len(fixtures)) + for _, fixture := range fixtures { + material := testMaterial(fixture.OriginReplicaID, fixture.LocalDecisionID, fixture.ResourceRef, fixture.Fields) + if fixture.CorruptDigest { + material.FieldsDigest = "corrupt" + } + events = append(events, testSyncEvent(t, material)) + } + return events +} diff --git a/harness/internal/productconfig/config.go b/harness/internal/productconfig/config.go index 16b97814..f6202d73 100644 --- a/harness/internal/productconfig/config.go +++ b/harness/internal/productconfig/config.go @@ -24,7 +24,6 @@ const ( RuntimeModeManagedOrHost = "managed-or-attached" ConnectionMultica = "multica" - ConnectionGitHub = "github" ConnectionMnemonhub = "mnemonhub" DefaultMulticaRuntimeBinary = multicasurface.MulticaRuntimeCommandName @@ -63,7 +62,6 @@ type HostRuntime struct { type Connections struct { Multica MulticaConnection `json:"multica,omitempty"` - GitHub GitHubConnection `json:"github,omitempty"` Mnemonhub MnemonhubConnection `json:"mnemonhub,omitempty"` } @@ -73,12 +71,6 @@ type MulticaConnection struct { RuntimeBinary string `json:"runtime_binary,omitempty"` } -type GitHubConnection struct { - Enabled bool `json:"enabled,omitempty"` - Repo string `json:"repo,omitempty"` - Branch string `json:"branch,omitempty"` -} - type MnemonhubConnection struct { Enabled bool `json:"enabled,omitempty"` Endpoint string `json:"endpoint,omitempty"` @@ -177,11 +169,6 @@ func (cfg Config) Validate() error { return fmt.Errorf("multica connection workspace is required when enabled") } } - if cfg.Connections.GitHub.Enabled { - if strings.TrimSpace(cfg.Connections.GitHub.Repo) == "" { - return fmt.Errorf("github connection repo is required when enabled") - } - } if cfg.Connections.Mnemonhub.Enabled { if strings.TrimSpace(cfg.Connections.Mnemonhub.Endpoint) == "" { return fmt.Errorf("mnemonhub connection endpoint is required when enabled") @@ -285,10 +272,6 @@ func validateCarrier(label, carrier string, cfg Config) error { if !cfg.Connections.Multica.Enabled { return fmt.Errorf("%s %q is not enabled", label, carrier) } - case ConnectionGitHub: - if !cfg.Connections.GitHub.Enabled { - return fmt.Errorf("%s %q is not enabled", label, carrier) - } case ConnectionMnemonhub: if !cfg.Connections.Mnemonhub.Enabled { return fmt.Errorf("%s %q is not enabled", label, carrier) @@ -315,8 +298,6 @@ type legacyRemoteEntry struct { Backend string `json:"backend,omitempty"` ID string `json:"id"` Endpoint string `json:"endpoint,omitempty"` - Repo string `json:"repo,omitempty"` - Branch string `json:"branch,omitempty"` } func FromLegacy(root string) (Config, bool, error) { @@ -355,12 +336,6 @@ func FromLegacy(root string) (Config, bool, error) { if strings.TrimSpace(cfg.Connections.Mnemonhub.Endpoint) == "" { cfg.Connections.Mnemonhub.Endpoint = strings.TrimSpace(remote.Endpoint) } - case ConnectionGitHub: - cfg.Connections.GitHub.Enabled = true - if strings.TrimSpace(cfg.Connections.GitHub.Repo) == "" { - cfg.Connections.GitHub.Repo = strings.TrimSpace(remote.Repo) - cfg.Connections.GitHub.Branch = strings.TrimSpace(remote.Branch) - } } } } @@ -377,10 +352,6 @@ func FromLegacy(root string) (Config, bool, error) { if cfg.Connections.Mnemonhub.Enabled { cfg.Daemon.InteractionWatchers = appendCarrier(cfg.Daemon.InteractionWatchers, ConnectionMnemonhub) } - if cfg.Connections.GitHub.Enabled { - cfg.Daemon.InteractionWatchers = appendCarrier(cfg.Daemon.InteractionWatchers, ConnectionGitHub) - cfg.Daemon.DisplaySurfaces = appendCarrier(cfg.Daemon.DisplaySurfaces, ConnectionGitHub) - } if len(cfg.Participants) > 0 { cfg.Daemon.DriveSources = appendCarrier(cfg.Daemon.DriveSources, DriveManagedLocal) } diff --git a/harness/internal/productconfig/config_test.go b/harness/internal/productconfig/config_test.go index e8c7baa3..7032e08d 100644 --- a/harness/internal/productconfig/config_test.go +++ b/harness/internal/productconfig/config_test.go @@ -184,9 +184,6 @@ func TestFromLegacyBridgesLocalAndRemoteConfigs(t *testing.T) { if len(cfg.Participants) != 1 || cfg.Participants[0].Principal != "planner@team" { t.Fatalf("participant bridge mismatch: %+v", cfg.Participants) } - if !cfg.Connections.GitHub.Enabled || cfg.Connections.GitHub.Repo != "mnemon-dev/mnemon-teamwork-example" { - t.Fatalf("github bridge mismatch: %+v", cfg.Connections.GitHub) - } if !cfg.Connections.Mnemonhub.Enabled || cfg.Connections.Mnemonhub.Endpoint != "https://hub.example" { t.Fatalf("mnemonhub bridge mismatch: %+v", cfg.Connections.Mnemonhub) } @@ -196,6 +193,9 @@ func TestFromLegacyBridgesLocalAndRemoteConfigs(t *testing.T) { if stringSliceContains(cfg.Daemon.DisplaySurfaces, ConnectionMnemonhub) { t.Fatalf("mnemonhub must not be bridged as a display surface: %+v", cfg.Daemon.DisplaySurfaces) } + if stringSliceContains(cfg.Daemon.InteractionWatchers, "github") || stringSliceContains(cfg.Daemon.DisplaySurfaces, "github") { + t.Fatalf("legacy github remotes must not bridge into product config: %+v", cfg.Daemon) + } if len(cfg.Daemon.DriveSources) != 1 || cfg.Daemon.DriveSources[0] != DriveManagedLocal { t.Fatalf("drive sources mismatch: %+v", cfg.Daemon.DriveSources) } diff --git a/harness/scripts/r3-1/cloudflare-live/run.sh b/harness/scripts/r3-1/cloudflare-live/run.sh new file mode 100755 index 00000000..dfa12063 --- /dev/null +++ b/harness/scripts/r3-1/cloudflare-live/run.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" +PHASE="cloudflare-live" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +ENV_FILE="${MNEMON_CLOUDFLARE_ENV_FILE:-$HOME/.mnemon/cloudflare-bootstrap.env}" +LOG="$PHASE_DIR/bootstrap.log" +WORKSPACE="$PHASE_DIR/workspace" +mkdir -p "$WORKSPACE" + +if [[ ! -f "$ENV_FILE" ]]; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Cloudflare env file missing: $ENV_FILE" + exit 0 +fi + +if [[ "$(stat -f '%Lp' "$ENV_FILE" 2>/dev/null || stat -c '%a' "$ENV_FILE" 2>/dev/null)" != "600" ]]; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status failed --failure "Cloudflare env file permissions must be 0600: $ENV_FILE" + exit 1 +fi + +if ! command -v wrangler >/dev/null 2>&1 && ! command -v npx >/dev/null 2>&1; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Neither wrangler nor npx is available for Cloudflare live deploy" + exit 0 +fi + +cd "$MNEMON_R3_ROOT" +set +e +go run ./harness/cmd/mnemon-harness hub bootstrap cloudflare \ + --root "$WORKSPACE" \ + --env-file "$ENV_FILE" \ + --principal "cloudflare-live@team" \ + --replica-id "cloudflare-live" \ + --scope "memory/project" \ + --remote "cloudflare-live" \ + --timeout "${MNEMON_CLOUDFLARE_BOOTSTRAP_TIMEOUT:-5m}" \ + >"$LOG" 2>&1 +STATUS=$? +set -e + +python3 "$SCRIPT_DIR/verify.py" \ + --summary "$PHASE_DIR/summary.json" \ + --log "$LOG" \ + --exit-code "$STATUS" \ + --workspace "$WORKSPACE" +exit "$?" diff --git a/harness/scripts/r3-1/cloudflare-live/verify.py b/harness/scripts/r3-1/cloudflare-live/verify.py new file mode 100644 index 00000000..0e29ad8e --- /dev/null +++ b/harness/scripts/r3-1/cloudflare-live/verify.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--log", required=True) + parser.add_argument("--exit-code", required=True, type=int) + parser.add_argument("--workspace", required=True) + args = parser.parse_args() + + log_text = Path(args.log).read_text(encoding="utf-8", errors="replace") + failures = [] + missing_workers_dev = "code: 10063" in log_text or "You need a workers.dev subdomain" in log_text + if args.exit_code != 0 and not missing_workers_dev: + failures.append({ + "category": "environment_failure", + "name": "cloudflare live bootstrap", + "detail": f"bootstrap exited {args.exit_code}; see {args.log}", + }) + + config_path = Path(args.workspace) / ".mnemon" / "harness" / "config.json" + remotes_path = Path(args.workspace) / ".mnemon" / "harness" / "sync" / "remotes.json" + assertions = [ + {"name": "mnemon-harness hub bootstrap cloudflare exited successfully", "passed": args.exit_code == 0}, + {"name": "bootstrap output includes connected marker", "passed": "MnemonHub connected" in log_text}, + {"name": "bootstrap output does not contain Cloudflare token literal", "passed": "cfat_" not in log_text and "CLOUDFLARE_API_TOKEN=" not in log_text}, + {"name": "local product config was written", "passed": config_path.exists()}, + {"name": "local sync remote was written", "passed": remotes_path.exists()}, + ] + skipped = [] + if missing_workers_dev: + skipped.append({ + "category": "skipped_missing_capability", + "name": "cloudflare workers.dev subdomain", + "detail": "Cloudflare API code 10063: open the Workers dashboard once to create the account workers.dev subdomain, then rerun this phase.", + }) + status = "skipped" if skipped else ("ok" if not failures and all(item["passed"] for item in assertions) else "failed") + summary = { + "schema_version": 1, + "phase": "cloudflare-live", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": [{ + "name": "go run ./harness/cmd/mnemon-harness hub bootstrap cloudflare --root --env-file ", + "status": "ok" if args.exit_code == 0 else "failed", + "exit_code": args.exit_code, + "log": args.log, + }], + "assertions": assertions, + "skipped": skipped, + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status in {"ok", "skipped"} else 1) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/cloudflare-local/run.sh b/harness/scripts/r3-1/cloudflare-local/run.sh new file mode 100755 index 00000000..8b698272 --- /dev/null +++ b/harness/scripts/r3-1/cloudflare-local/run.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" +PHASE="cloudflare-local" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +LOG="$PHASE_DIR/npm-test.log" +cd "$MNEMON_R3_ROOT/harness/cloudflare/mnemonhub" +set +e +npm test >"$LOG" 2>&1 +STATUS=$? +set -e + +python3 "$SCRIPT_DIR/verify.py" \ + --summary "$PHASE_DIR/summary.json" \ + --log "$LOG" \ + --exit-code "$STATUS" +exit "$STATUS" diff --git a/harness/scripts/r3-1/cloudflare-local/verify.py b/harness/scripts/r3-1/cloudflare-local/verify.py new file mode 100644 index 00000000..77aad0a6 --- /dev/null +++ b/harness/scripts/r3-1/cloudflare-local/verify.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--log", required=True) + parser.add_argument("--exit-code", required=True, type=int) + args = parser.parse_args() + + log_text = Path(args.log).read_text(encoding="utf-8", errors="replace") + failures = [] + if args.exit_code != 0: + failures.append({ + "category": "protocol_failure", + "name": "cloudflare mnemonhub local contract", + "detail": f"npm test exited {args.exit_code}; see {args.log}", + }) + + assertions = [ + {"name": "npm test passes Cloudflare MnemonHub sync ABI fixtures", "passed": args.exit_code == 0}, + {"name": "Cloudflare local log does not mention API token", "passed": "CLOUDFLARE_API_TOKEN=" not in log_text}, + {"name": "Cloudflare local log does not mention bearer secret literals", "passed": "cfat_" not in log_text}, + ] + status = "ok" if not failures and all(item["passed"] for item in assertions) else "failed" + summary = { + "schema_version": 1, + "phase": "cloudflare-local", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": [{ + "name": "cd harness/cloudflare/mnemonhub && npm test", + "status": "ok" if args.exit_code == 0 else "failed", + "exit_code": args.exit_code, + "log": args.log, + }], + "assertions": assertions, + "skipped": [], + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status == "ok" else 1) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/docker-cloudflare/run.sh b/harness/scripts/r3-1/docker-cloudflare/run.sh new file mode 100755 index 00000000..df136013 --- /dev/null +++ b/harness/scripts/r3-1/docker-cloudflare/run.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" + +PHASE="docker-cloudflare" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +ENV_FILE="${MNEMON_CLOUDFLARE_ENV_FILE:-$HOME/.mnemon/cloudflare-bootstrap.env}" +BOOTSTRAP_LOG="$PHASE_DIR/bootstrap.log" +PROBE_LOG="$PHASE_DIR/docker-probe.log" +WORKSPACE="$PHASE_DIR/workspace" +mkdir -p "$WORKSPACE" + +if [[ ! -f "$ENV_FILE" ]]; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Cloudflare env file missing: $ENV_FILE" + exit 0 +fi + +if [[ "$(stat -f '%Lp' "$ENV_FILE" 2>/dev/null || stat -c '%a' "$ENV_FILE" 2>/dev/null)" != "600" ]]; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status failed --failure "Cloudflare env file permissions must be 0600: $ENV_FILE" + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "docker command is not available" + exit 0 +fi + +if ! command -v wrangler >/dev/null 2>&1 && ! command -v npx >/dev/null 2>&1; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Neither wrangler nor npx is available for Cloudflare live deploy" + exit 0 +fi + +cd "$MNEMON_R3_ROOT" +set +e +go run ./harness/cmd/mnemon-harness hub bootstrap cloudflare \ + --root "$WORKSPACE" \ + --env-file "$ENV_FILE" \ + --principal "cloudflare-docker@team" \ + --replica-id "cloudflare-docker" \ + --scope "memory/project" \ + --remote "cloudflare-docker" \ + --timeout "${MNEMON_CLOUDFLARE_BOOTSTRAP_TIMEOUT:-5m}" \ + >"$BOOTSTRAP_LOG" 2>&1 +BOOTSTRAP_STATUS=$? +set -e + +ENDPOINT="$(awk '/^Endpoint: / {print $2}' "$BOOTSTRAP_LOG" | tail -1)" +TOKEN_FILE="$WORKSPACE/.mnemon/harness/sync/credentials/cloudflare-docker.token" +TOKEN="" +if [[ -f "$TOKEN_FILE" ]]; then + TOKEN="$(tr -d '\r\n' < "$TOKEN_FILE")" +fi + +PROBE_STATUS=1 +if [[ "$BOOTSTRAP_STATUS" -eq 0 && -n "$ENDPOINT" && -n "$TOKEN" ]]; then + DECISION_ID="docker-cloudflare-$(date -u +"%Y%m%d%H%M%S")" + set +e + docker run --rm \ + -v "$MNEMON_R3_ROOT:/workspace:ro" \ + -v mnemon-r3-docker-gomodcache:/go/pkg/mod \ + -v mnemon-r3-docker-gocache:/root/.cache/go-build \ + -w /workspace \ + golang:1.24 \ + bash -c "go run ./harness/scripts/r3-1/docker/probe.go --mode push --endpoint '$ENDPOINT' --token '$TOKEN' --replica-id cloudflare-docker --decision-id '$DECISION_ID' --content 'Docker 到 Cloudflare Durable Object 的中文同步事件' && go run ./harness/scripts/r3-1/docker/probe.go --mode status --endpoint '$ENDPOINT' --token '$TOKEN' --replica-id cloudflare-docker" \ + >"$PROBE_LOG" 2>&1 + PROBE_STATUS=$? + set -e +fi + +python3 "$SCRIPT_DIR/verify.py" \ + --summary "$PHASE_DIR/summary.json" \ + --bootstrap-log "$BOOTSTRAP_LOG" \ + --bootstrap-exit "$BOOTSTRAP_STATUS" \ + --probe-log "$PROBE_LOG" \ + --probe-exit "$PROBE_STATUS" \ + --endpoint "$ENDPOINT" diff --git a/harness/scripts/r3-1/docker-cloudflare/verify.py b/harness/scripts/r3-1/docker-cloudflare/verify.py new file mode 100644 index 00000000..48629f89 --- /dev/null +++ b/harness/scripts/r3-1/docker-cloudflare/verify.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def read(path): + p = Path(path) + if not p.exists(): + return "" + return p.read_text(encoding="utf-8", errors="replace") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--bootstrap-log", required=True) + parser.add_argument("--bootstrap-exit", required=True, type=int) + parser.add_argument("--probe-log", required=True) + parser.add_argument("--probe-exit", required=True, type=int) + parser.add_argument("--endpoint", required=True) + args = parser.parse_args() + + bootstrap = read(args.bootstrap_log) + probe = read(args.probe_log) + logs = bootstrap + "\n" + probe + missing_workers_dev = "code: 10063" in bootstrap or "You need a workers.dev subdomain" in bootstrap + assertions = [ + {"name": "cloudflare bootstrap exited successfully", "passed": args.bootstrap_exit == 0}, + {"name": "bootstrap reported workers.dev endpoint", "passed": args.endpoint.startswith("https://") and args.endpoint.endswith(".workers.dev")}, + {"name": "docker probe pushed one event", "passed": args.probe_exit == 0 and '"accepted": 1' in probe}, + {"name": "docker probe status saw received events", "passed": args.probe_exit == 0 and '"received":' in probe}, + {"name": "logs do not contain Cloudflare API token literal", "passed": "cfat_" not in logs and "CLOUDFLARE_API_TOKEN=" not in logs}, + ] + skipped = [] + if missing_workers_dev: + skipped.append({ + "category": "skipped_missing_capability", + "name": "cloudflare workers.dev subdomain", + "detail": "Cloudflare API code 10063: account workers.dev subdomain is not enabled.", + }) + failures = [] + if not skipped and not all(item["passed"] for item in assertions): + failures.append({ + "category": "test_failure", + "name": "docker Cloudflare MnemonHub sync", + "detail": f"see {args.bootstrap_log} and {args.probe_log}", + }) + status = "skipped" if skipped else ("ok" if not failures else "failed") + summary = { + "schema_version": 1, + "phase": "docker-cloudflare", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": [ + {"name": "mnemon-harness hub bootstrap cloudflare", "status": "ok" if args.bootstrap_exit == 0 else "failed", "exit_code": args.bootstrap_exit, "log": args.bootstrap_log}, + {"name": "docker run golang:1.24 go run docker/probe.go", "status": "ok" if args.probe_exit == 0 else "failed", "exit_code": args.probe_exit, "log": args.probe_log}, + ], + "assertions": assertions, + "skipped": skipped, + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status in {"ok", "skipped"} else 1) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/docker-local/run.sh b/harness/scripts/r3-1/docker-local/run.sh new file mode 100755 index 00000000..80143365 --- /dev/null +++ b/harness/scripts/r3-1/docker-local/run.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" + +PHASE="docker-local" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +STATE_DIR="$PHASE_DIR/state" +REPLICA_DIR="$STATE_DIR/replicas" +mkdir -p "$REPLICA_DIR" +chmod 700 "$STATE_DIR" "$REPLICA_DIR" +printf 'token-a\n' > "$REPLICA_DIR/a.token" +printf 'token-b\n' > "$REPLICA_DIR/b.token" +chmod 600 "$REPLICA_DIR/a.token" "$REPLICA_DIR/b.token" +cat > "$REPLICA_DIR/replicas.json" <<'JSON' +{ + "schema_version": 1, + "replicas": [ + { + "principal": "replica-a@docker", + "credential_ref": "a.token", + "scopes": [{"kind": "memory", "id": "project"}] + }, + { + "principal": "replica-b@docker", + "credential_ref": "b.token", + "scopes": [{"kind": "memory", "id": "project"}] + } + ] +} +JSON +chmod 600 "$REPLICA_DIR/replicas.json" + +COMPOSE="$SCRIPT_DIR/../docker/compose.yml" +PROJECT="mnemon-r3-local-$(printf '%s' "${MNEMON_R3_RUN_ID:-manual}" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_-' '-')" +HUB_LOG="$PHASE_DIR/hub.log" +NODE_A_LOG="$PHASE_DIR/node-a.log" +NODE_B_LOG="$PHASE_DIR/node-b.log" +DOWN_LOG="$PHASE_DIR/compose-down.log" +export MNEMON_R3_DOCKER_WORK="$STATE_DIR" + +cd "$MNEMON_R3_ROOT" +set +e +docker compose -p "$PROJECT" -f "$COMPOSE" up -d hub >"$HUB_LOG" 2>&1 +UP_STATUS=$? +if [[ "$UP_STATUS" -eq 0 ]]; then + docker compose -p "$PROJECT" -f "$COMPOSE" run --rm node-a >"$NODE_A_LOG" 2>&1 + NODE_A_STATUS=$? +else + NODE_A_STATUS=1 +fi +if [[ "$UP_STATUS" -eq 0 && "$NODE_A_STATUS" -eq 0 ]]; then + docker compose -p "$PROJECT" -f "$COMPOSE" run --rm node-b >"$NODE_B_LOG" 2>&1 + NODE_B_STATUS=$? +else + NODE_B_STATUS=1 +fi +docker compose -p "$PROJECT" -f "$COMPOSE" logs hub >>"$HUB_LOG" 2>&1 +docker compose -p "$PROJECT" -f "$COMPOSE" down -v >"$DOWN_LOG" 2>&1 +DOWN_STATUS=$? +set -e + +python3 "$SCRIPT_DIR/verify.py" \ + --summary "$PHASE_DIR/summary.json" \ + --up-exit "$UP_STATUS" \ + --node-a-exit "$NODE_A_STATUS" \ + --node-b-exit "$NODE_B_STATUS" \ + --down-exit "$DOWN_STATUS" \ + --hub-log "$HUB_LOG" \ + --node-a-log "$NODE_A_LOG" \ + --node-b-log "$NODE_B_LOG" \ + --down-log "$DOWN_LOG" diff --git a/harness/scripts/r3-1/docker-local/verify.py b/harness/scripts/r3-1/docker-local/verify.py new file mode 100644 index 00000000..39256eb9 --- /dev/null +++ b/harness/scripts/r3-1/docker-local/verify.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def read(path): + p = Path(path) + if not p.exists(): + return "" + return p.read_text(encoding="utf-8", errors="replace") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--up-exit", required=True, type=int) + parser.add_argument("--node-a-exit", required=True, type=int) + parser.add_argument("--node-b-exit", required=True, type=int) + parser.add_argument("--down-exit", required=True, type=int) + parser.add_argument("--hub-log", required=True) + parser.add_argument("--node-a-log", required=True) + parser.add_argument("--node-b-log", required=True) + parser.add_argument("--down-log", required=True) + args = parser.parse_args() + + hub = read(args.hub_log) + node_a = read(args.node_a_log) + node_b = read(args.node_b_log) + all_logs = "\n".join([hub, node_a, node_b, read(args.down_log)]) + assertions = [ + {"name": "docker compose started hub", "passed": args.up_exit == 0}, + {"name": "node-a pushed one synced event", "passed": args.node_a_exit == 0 and '"accepted": 1' in node_a}, + {"name": "node-b pulled at least one synced event", "passed": args.node_b_exit == 0 and '"events": 1' in node_b}, + {"name": "node-b status saw received event", "passed": args.node_b_exit == 0 and '"received": 1' in node_b}, + {"name": "hub audit saw push and pull", "passed": "verb=sync.push result=ok" in hub and "verb=sync.pull result=ok" in hub}, + {"name": "compose cleanup completed", "passed": args.down_exit == 0}, + {"name": "logs do not contain Cloudflare token literal", "passed": "cfat_" not in all_logs}, + ] + failures = [] + if not all(item["passed"] for item in assertions): + failures.append({ + "category": "test_failure", + "name": "docker local MnemonHub sync", + "detail": f"see {args.hub_log}, {args.node_a_log}, and {args.node_b_log}", + }) + status = "ok" if not failures else "failed" + summary = { + "schema_version": 1, + "phase": "docker-local", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": [ + {"name": "docker compose up -d hub", "status": "ok" if args.up_exit == 0 else "failed", "exit_code": args.up_exit, "log": args.hub_log}, + {"name": "docker compose run --rm node-a", "status": "ok" if args.node_a_exit == 0 else "failed", "exit_code": args.node_a_exit, "log": args.node_a_log}, + {"name": "docker compose run --rm node-b", "status": "ok" if args.node_b_exit == 0 else "failed", "exit_code": args.node_b_exit, "log": args.node_b_log}, + {"name": "docker compose down -v", "status": "ok" if args.down_exit == 0 else "failed", "exit_code": args.down_exit, "log": args.down_log}, + ], + "assertions": assertions, + "skipped": [], + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status == "ok" else 1) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/docker/compose.yml b/harness/scripts/r3-1/docker/compose.yml new file mode 100644 index 00000000..0a99b231 --- /dev/null +++ b/harness/scripts/r3-1/docker/compose.yml @@ -0,0 +1,58 @@ +services: + hub: + image: golang:1.24 + working_dir: /workspace + command: + - bash + - -c + - go run ./harness/cmd/mnemon-hub serve --addr 0.0.0.0:9787 --store /state/hub.db --replicas /state/replicas/replicas.json + volumes: + - ${MNEMON_R3_ROOT:-../../..}:/workspace:ro + - ${MNEMON_R3_DOCKER_WORK:?set MNEMON_R3_DOCKER_WORK}:/state + - gomodcache:/go/pkg/mod + - gocache:/root/.cache/go-build + ports: + - "9787" + + node-a: + image: golang:1.24 + working_dir: /workspace + profiles: ["run"] + command: + - go + - run + - ./harness/scripts/r3-1/docker/probe.go + - --mode + - push + - --endpoint + - http://hub:9787 + - --token + - token-a + - --replica-id + - local-a + - --decision-id + - docker-local-a + - --content + - "Docker 本地多节点同步: A 推送中文事件" + - --allow-insecure + volumes: + - ${MNEMON_R3_ROOT:-../../..}:/workspace:ro + - gomodcache:/go/pkg/mod + - gocache:/root/.cache/go-build + + node-b: + image: golang:1.24 + working_dir: /workspace + profiles: ["run"] + command: + - bash + - -c + - go run ./harness/scripts/r3-1/docker/probe.go --mode pull --endpoint http://hub:9787 --token token-b --replica-id local-b --allow-insecure && go run ./harness/scripts/r3-1/docker/probe.go --mode status --endpoint http://hub:9787 --token token-b --replica-id local-b --allow-insecure + volumes: + - ${MNEMON_R3_ROOT:-../../..}:/workspace:ro + - gomodcache:/go/pkg/mod + - gocache:/root/.cache/go-build + +volumes: + gomodcache: + gocache: diff --git a/harness/scripts/r3-1/docker/probe.go b/harness/scripts/r3-1/docker/probe.go new file mode 100644 index 00000000..3e87203e --- /dev/null +++ b/harness/scripts/r3-1/docker/probe.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + mode := flag.String("mode", "", "push, pull, or status") + endpoint := flag.String("endpoint", "", "MnemonHub endpoint") + token := flag.String("token", "", "replica bearer token") + replicaID := flag.String("replica-id", "", "local replica id") + decisionID := flag.String("decision-id", "docker-probe", "local decision id") + content := flag.String("content", "docker probe", "event content") + allowInsecure := flag.Bool("allow-insecure", false, "allow plaintext non-loopback endpoint for Docker local tests") + flag.Parse() + + if strings.TrimSpace(*mode) == "" || strings.TrimSpace(*endpoint) == "" || strings.TrimSpace(*token) == "" || strings.TrimSpace(*replicaID) == "" { + return fmt.Errorf("--mode, --endpoint, --token, and --replica-id are required") + } + client, err := access.NewSyncClient(*endpoint, access.SyncClientConfig{Token: *token, AllowInsecure: *allowInsecure}) + if err != nil { + return err + } + if err := waitStatus(client, 30*time.Second); err != nil { + return err + } + switch *mode { + case "push": + return push(client, *replicaID, *decisionID, *content) + case "pull": + return pull(client, *replicaID) + case "status": + return status(client) + default: + return fmt.Errorf("unknown --mode %q", *mode) + } +} + +func waitStatus(client *access.Client, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + var lastErr error + for ctx.Err() == nil { + if _, err := client.SyncStatus(); err == nil { + return nil + } else { + lastErr = err + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("hub did not become ready: %w", lastErr) +} + +func push(client *access.Client, replicaID, decisionID, content string) error { + fields := map[string]any{"content": content} + fieldsJSON, _ := json.Marshal(fields) + sum := sha256.Sum256(fieldsJSON) + env, err := contract.SyncedEventEnvelopeFromMaterial(contract.SyncedEventMaterial{ + OriginReplicaID: replicaID, + LocalDecisionID: decisionID, + LocalIngestSeq: 1, + Actor: contract.ActorID(replicaID + "@docker"), + ResourceRef: contract.ResourceRef{Kind: "memory", ID: "project"}, + ResourceVersion: 1, + FieldsDigest: hex.EncodeToString(sum[:]), + Fields: fields, + DecidedAt: time.Now().UTC().Format(time.RFC3339), + Status: "pending", + }) + if err != nil { + return err + } + resp, err := client.SyncPush(contract.SyncPushRequest{ReplicaID: replicaID, BatchID: decisionID, Events: []eventmodel.EventEnvelope{env}}) + if err != nil { + return err + } + if len(resp.Accepted) != 1 || len(resp.Rejected) != 0 || len(resp.Conflicts) != 0 { + return fmt.Errorf("push mismatch: accepted=%d rejected=%d conflicts=%d %+v", len(resp.Accepted), len(resp.Rejected), len(resp.Conflicts), resp) + } + return printJSON(map[string]any{"mode": "push", "accepted": len(resp.Accepted), "next_cursor": resp.NextCursor}) +} + +func pull(client *access.Client, replicaID string) error { + resp, err := client.SyncPull(contract.SyncPullRequest{ReplicaID: replicaID}) + if err != nil { + return err + } + if len(resp.Events) < 1 { + return fmt.Errorf("pull returned no events") + } + return printJSON(map[string]any{"mode": "pull", "events": len(resp.Events), "next_cursor": resp.NextCursor}) +} + +func status(client *access.Client) error { + resp, err := client.SyncStatus() + if err != nil { + return err + } + if resp.HubEventsReceived < 1 { + return fmt.Errorf("status received count = %d, want >= 1", resp.HubEventsReceived) + } + return printJSON(map[string]any{"mode": "status", "received": resp.HubEventsReceived, "served": resp.HubEventsServed, "principal": resp.Principal}) +} + +func printJSON(value any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(value) +} diff --git a/harness/scripts/r3-1/lib/common.sh b/harness/scripts/r3-1/lib/common.sh new file mode 100644 index 00000000..9e9fc1ea --- /dev/null +++ b/harness/scripts/r3-1/lib/common.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -uo pipefail + +mnemon_r3_script_dir() { + cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd +} + +mnemon_r3_root() { + local dir + dir="$(mnemon_r3_script_dir)" + cd "$dir/../../.." && pwd +} + +mnemon_r3_timestamp() { + date -u +"%Y%m%dT%H%M%SZ" +} + +mnemon_r3_out_dir() { + local root="${MNEMON_R3_ROOT:-$(mnemon_r3_root)}" + local stamp="${MNEMON_R3_RUN_ID:-$(mnemon_r3_timestamp)}" + printf "%s/.mnemon-dev/tmp/r3-1-test/%s" "$root" "$stamp" +} + +mnemon_r3_phase_dir() { + local phase="$1" + local out="${MNEMON_R3_OUT_DIR:-$(mnemon_r3_out_dir)}" + printf "%s/%s" "$out" "$phase" +} + +mnemon_r3_prepare_phase() { + local phase="$1" + local dir + dir="$(mnemon_r3_phase_dir "$phase")" + mkdir -p "$dir" + printf "%s" "$dir" +} + +mnemon_r3_log() { + printf "[r3-1] %s\n" "$*" +} diff --git a/harness/scripts/r3-1/lib/report.py b/harness/scripts/r3-1/lib/report.py new file mode 100755 index 00000000..ac2c753b --- /dev/null +++ b/harness/scripts/r3-1/lib/report.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def write_summary(path, phase, status, commands=None, assertions=None, skipped=None, failures=None): + data = { + "schema_version": 1, + "phase": phase, + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": commands or [], + "assertions": assertions or [], + "skipped": skipped or [], + "failures": failures or [], + } + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return data + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--phase", required=True) + parser.add_argument("--status", required=True, choices=["ok", "failed", "skipped"]) + parser.add_argument("--skip", action="append", default=[]) + parser.add_argument("--failure", action="append", default=[]) + args = parser.parse_args() + + skipped = [{"category": "skipped_missing_capability", "detail": item} for item in args.skip] + failures = [{"category": "environment_failure", "detail": item} for item in args.failure] + write_summary(args.summary, args.phase, args.status, skipped=skipped, failures=failures) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/live-multica/run.sh b/harness/scripts/r3-1/live-multica/run.sh new file mode 100755 index 00000000..c7957183 --- /dev/null +++ b/harness/scripts/r3-1/live-multica/run.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" + +PHASE="live-multica" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +PROFILE="${MNEMON_MULTICA_PROFILE:-desktop-api.multica.ai}" +WORKSPACE_ID="${MNEMON_MULTICA_WORKSPACE_ID:-}" +DAEMON_LOG="$PHASE_DIR/daemon-status.json" +RUNTIMES_LOG="$PHASE_DIR/runtimes.json" +AGENTS_LOG="$PHASE_DIR/agents.json" +CREATE_LOG="$PHASE_DIR/issue-create.json" +RUNS_LOG="$PHASE_DIR/issue-runs.json" +MESSAGES_LOG="$PHASE_DIR/run-messages.json" +META_LOG="$PHASE_DIR/live-meta.json" + +if ! command -v multica >/dev/null 2>&1; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "multica CLI is not available" + exit 0 +fi + +set +e +multica --profile "$PROFILE" daemon status --output json >"$DAEMON_LOG" 2>&1 +DAEMON_STATUS=$? +set -e +if [[ "$DAEMON_STATUS" -ne 0 ]]; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Multica daemon/profile is not ready; see $DAEMON_LOG" + exit 0 +fi + +if [[ -z "$WORKSPACE_ID" ]]; then + WORKSPACE_ID="$(python3 - "$DAEMON_LOG" <<'PY' +import json, sys +data = json.load(open(sys.argv[1], encoding="utf-8")) +workspaces = data.get("workspaces") or [] +print((workspaces[0] or {}).get("id", "") if workspaces else "") +PY +)" +fi +if [[ -z "$WORKSPACE_ID" ]]; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Multica daemon did not report a workspace id" + exit 0 +fi + +set +e +multica --profile "$PROFILE" --workspace-id "$WORKSPACE_ID" runtime list --output json >"$RUNTIMES_LOG" 2>&1 +RUNTIMES_STATUS=$? +multica --profile "$PROFILE" --workspace-id "$WORKSPACE_ID" agent list --output json >"$AGENTS_LOG" 2>&1 +AGENTS_STATUS=$? +set -e + +RUNTIME_ID="" +AGENT_NAME="" +if [[ "$RUNTIMES_STATUS" -eq 0 && "$AGENTS_STATUS" -eq 0 ]]; then + RUNTIME_ID="$(python3 - "$RUNTIMES_LOG" <<'PY' +import json, sys +items = json.load(open(sys.argv[1], encoding="utf-8")) +for item in items: + if item.get("status") == "online" and "mnemon-runtime" in str(item.get("name", "")): + print(item.get("id", "")) + break +PY +)" + AGENT_NAME="$(python3 - "$AGENTS_LOG" "$RUNTIME_ID" <<'PY' +import json, sys +items = json.load(open(sys.argv[1], encoding="utf-8")) +runtime_id = sys.argv[2] +for item in items: + if item.get("name") == "mnemon-planner" and (not runtime_id or item.get("runtime_id") == runtime_id): + print(item.get("name", "")) + break +PY +)" +fi + +CREATE_STATUS=1 +RUNS_STATUS=1 +MESSAGES_STATUS=1 +ISSUE_ID="" +TASK_ID="" +if [[ -n "$RUNTIME_ID" && -n "$AGENT_NAME" ]]; then + TITLE="R3-1 live Multica runtime smoke ${MNEMON_R3_RUN_ID:-manual}" + DESCRIPTION="中文 live smoke:验证 mnemon-multica-runtime 在 Multica 中以 codex app-server 形态在线,并能通过 issue 分配触发。" + set +e + multica --profile "$PROFILE" --workspace-id "$WORKSPACE_ID" issue create \ + --title "$TITLE" \ + --description "$DESCRIPTION" \ + --assignee "$AGENT_NAME" \ + --status todo \ + --priority medium \ + --output json >"$CREATE_LOG" 2>&1 + CREATE_STATUS=$? + set -e + if [[ "$CREATE_STATUS" -eq 0 ]]; then + ISSUE_ID="$(python3 - "$CREATE_LOG" <<'PY' +import json, sys +data = json.load(open(sys.argv[1], encoding="utf-8")) +print(data.get("id", "")) +PY +)" + fi +fi + +if [[ -n "$ISSUE_ID" ]]; then + for _ in $(seq 1 18); do + set +e + multica --profile "$PROFILE" --workspace-id "$WORKSPACE_ID" issue runs "$ISSUE_ID" --output json >"$RUNS_LOG" 2>&1 + RUNS_STATUS=$? + set -e + if [[ "$RUNS_STATUS" -eq 0 ]]; then + TASK_ID="$(python3 - "$RUNS_LOG" <<'PY' +import json, sys +data = json.load(open(sys.argv[1], encoding="utf-8")) +if isinstance(data, dict): + data = data.get("runs") or data.get("items") or [] +if data: + first = data[0] + print(first.get("id") or first.get("task_id") or "") +PY +)" + [[ -n "$TASK_ID" ]] && break + fi + sleep 5 + done +fi + +if [[ -n "$TASK_ID" ]]; then + set +e + multica --profile "$PROFILE" --workspace-id "$WORKSPACE_ID" issue run-messages "$TASK_ID" --output json >"$MESSAGES_LOG" 2>&1 + MESSAGES_STATUS=$? + set -e +fi + +python3 - "$META_LOG" <"$LOG" 2>&1 +STATUS=$? +set -e + +python3 "$SCRIPT_DIR/verify.py" \ + --summary "$PHASE_DIR/summary.json" \ + --log "$LOG" \ + --exit-code "$STATUS" +exit "$STATUS" diff --git a/harness/scripts/r3-1/mnemonhub-contract/verify.py b/harness/scripts/r3-1/mnemonhub-contract/verify.py new file mode 100755 index 00000000..87542788 --- /dev/null +++ b/harness/scripts/r3-1/mnemonhub-contract/verify.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--log", required=True) + parser.add_argument("--exit-code", required=True, type=int) + args = parser.parse_args() + + log_text = Path(args.log).read_text(encoding="utf-8", errors="replace") + failures = [] + if args.exit_code != 0: + failures.append({ + "category": "protocol_failure", + "name": "mnemonhub contract go tests", + "detail": f"go test exited {args.exit_code}; see {args.log}", + }) + + assertions = [ + {"name": "go test ./harness/internal/event ./harness/internal/contract ./harness/internal/mnemonhub ./harness/internal/mnemonhub/... ./harness/internal/coreguard ./harness/internal/productconfig", "passed": args.exit_code == 0}, + {"name": "mnemonhub output does not mention Cloudflare token", "passed": "CLOUDFLARE_API_TOKEN=" not in log_text}, + ] + status = "ok" if not failures and all(a["passed"] for a in assertions) else "failed" + summary = { + "schema_version": 1, + "phase": "mnemonhub-contract", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": [{ + "name": "go test ./harness/internal/event ./harness/internal/contract ./harness/internal/mnemonhub ./harness/internal/mnemonhub/... ./harness/internal/coreguard ./harness/internal/productconfig", + "status": "ok" if args.exit_code == 0 else "failed", + "exit_code": args.exit_code, + "log": args.log, + }], + "assertions": assertions, + "skipped": [], + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status == "ok" else 1) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/multica-runtime-gate/run.sh b/harness/scripts/r3-1/multica-runtime-gate/run.sh new file mode 100755 index 00000000..e99ae669 --- /dev/null +++ b/harness/scripts/r3-1/multica-runtime-gate/run.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" + +PHASE="multica-runtime-gate" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +LOG="$PHASE_DIR/go-test.log" +cd "$MNEMON_R3_ROOT" +set +e +go test -v ./harness/cmd/mnemon-multica-runtime -run 'TestRuntime(ProxiesProviderAppServerWhenNoIssueGateIsPresent|GateDoesNotStartProviderForIssueActivation|GateUsesTurnIssueTagBeforeStartingProvider|ProviderCommandDefaultsToCodexJSONRPC)$' >"$LOG" 2>&1 +STATUS=$? +set -e + +python3 "$SCRIPT_DIR/verify.py" \ + --summary "$PHASE_DIR/summary.json" \ + --log "$LOG" \ + --exit-code "$STATUS" +exit "$STATUS" diff --git a/harness/scripts/r3-1/multica-runtime-gate/verify.py b/harness/scripts/r3-1/multica-runtime-gate/verify.py new file mode 100644 index 00000000..ff92518b --- /dev/null +++ b/harness/scripts/r3-1/multica-runtime-gate/verify.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--log", required=True) + parser.add_argument("--exit-code", required=True, type=int) + args = parser.parse_args() + + log_text = Path(args.log).read_text(encoding="utf-8", errors="replace") + wanted = [ + "TestRuntimeProxiesProviderAppServerWhenNoIssueGateIsPresent", + "TestRuntimeGateDoesNotStartProviderForIssueActivation", + "TestRuntimeGateUsesTurnIssueTagBeforeStartingProvider", + "TestRuntimeProviderCommandDefaultsToCodexJSONRPC", + ] + assertions = [ + {"name": "gate-focused runtime tests exited successfully", "passed": args.exit_code == 0}, + {"name": "go test selected mnemon-multica-runtime package", "passed": "github.com/mnemon-dev/mnemon/harness/cmd/mnemon-multica-runtime" in log_text}, + ] + for name in wanted: + assertions.append({"name": f"{name} executed", "passed": name in log_text}) + + failures = [] + if args.exit_code != 0: + failures.append({ + "category": "test_failure", + "name": "multica runtime gate tests", + "detail": f"go test exited {args.exit_code}; see {args.log}", + }) + status = "ok" if not failures and all(item["passed"] for item in assertions) else "failed" + summary = { + "schema_version": 1, + "phase": "multica-runtime-gate", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": [{ + "name": "go test ./harness/cmd/mnemon-multica-runtime -run ", + "status": "ok" if args.exit_code == 0 else "failed", + "exit_code": args.exit_code, + "log": args.log, + }], + "assertions": assertions, + "skipped": [], + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status == "ok" else 1) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/multica-writeback/run.sh b/harness/scripts/r3-1/multica-writeback/run.sh new file mode 100755 index 00000000..18762291 --- /dev/null +++ b/harness/scripts/r3-1/multica-writeback/run.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" + +PHASE="multica-writeback" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +LOG="$PHASE_DIR/go-test.log" +cd "$MNEMON_R3_ROOT" +set +e +go test -v ./harness/cmd/mnemon-harness ./harness/internal/coreguard -run 'Test(MulticaSurfaceReportWritesDisplayOnlyState|MulticaActivationCarrierCreatesTriggerIssue|MulticaRuntimeDoesNotOwnManagedWakeOrDisplayWriteback|MnemondAndMnemonHubDoNotDependOnMulticaSurface)$' >"$LOG" 2>&1 +STATUS=$? +set -e + +python3 "$SCRIPT_DIR/verify.py" \ + --summary "$PHASE_DIR/summary.json" \ + --log "$LOG" \ + --exit-code "$STATUS" +exit "$STATUS" diff --git a/harness/scripts/r3-1/multica-writeback/verify.py b/harness/scripts/r3-1/multica-writeback/verify.py new file mode 100644 index 00000000..414a5732 --- /dev/null +++ b/harness/scripts/r3-1/multica-writeback/verify.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--log", required=True) + parser.add_argument("--exit-code", required=True, type=int) + args = parser.parse_args() + + log_text = Path(args.log).read_text(encoding="utf-8", errors="replace") + wanted = [ + "TestMulticaSurfaceReportWritesDisplayOnlyState", + "TestMulticaActivationCarrierCreatesTriggerIssue", + "TestMulticaRuntimeDoesNotOwnManagedWakeOrDisplayWriteback", + "TestMnemondAndMnemonHubDoNotDependOnMulticaSurface", + ] + assertions = [ + {"name": "writeback boundary tests exited successfully", "passed": args.exit_code == 0}, + {"name": "display writeback does not use wake tag", "passed": "[mnemon:wake]" not in log_text}, + {"name": "test log does not contain Cloudflare token literal", "passed": "cfat_" not in log_text}, + ] + for name in wanted: + assertions.append({"name": f"{name} executed", "passed": name in log_text}) + + failures = [] + if args.exit_code != 0: + failures.append({ + "category": "test_failure", + "name": "multica writeback boundary tests", + "detail": f"go test exited {args.exit_code}; see {args.log}", + }) + status = "ok" if not failures and all(item["passed"] for item in assertions) else "failed" + summary = { + "schema_version": 1, + "phase": "multica-writeback", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": [{ + "name": "go test ./harness/cmd/mnemon-harness ./harness/internal/coreguard -run ", + "status": "ok" if args.exit_code == 0 else "failed", + "exit_code": args.exit_code, + "log": args.log, + }], + "assertions": assertions, + "skipped": [], + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status == "ok" else 1) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/preflight/run.sh b/harness/scripts/r3-1/preflight/run.sh new file mode 100755 index 00000000..a5a4aae8 --- /dev/null +++ b/harness/scripts/r3-1/preflight/run.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" + +PHASE="preflight" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +python3 "$SCRIPT_DIR/verify.py" \ + --root "$MNEMON_R3_ROOT" \ + --summary "$PHASE_DIR/summary.json" diff --git a/harness/scripts/r3-1/preflight/verify.py b/harness/scripts/r3-1/preflight/verify.py new file mode 100755 index 00000000..c0c670b6 --- /dev/null +++ b/harness/scripts/r3-1/preflight/verify.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import shutil +import stat +import subprocess +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def command_available(name): + return shutil.which(name) is not None + + +def run_git_status(root): + proc = subprocess.run( + ["git", "status", "--short", "--branch"], + cwd=root, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return { + "name": "git status --short --branch", + "status": "ok" if proc.returncode == 0 else "failed", + "exit_code": proc.returncode, + "stdout": proc.stdout.strip(), + "stderr": proc.stderr.strip(), + } + + +def env_file_check(): + path = Path.home() / ".mnemon" / "cloudflare-bootstrap.env" + if not path.exists(): + return None, {"category": "skipped_missing_capability", "name": "cloudflare env", "detail": str(path)} + mode = stat.S_IMODE(path.stat().st_mode) + ok = mode == 0o600 + assertion = { + "name": "cloudflare bootstrap env permissions are 0600", + "passed": ok, + "detail": oct(mode), + } + allowed = {"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "MNEMON_CLOUDFLARE_WORKER_NAME"} + extra = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key = line.split("=", 1)[0].strip().removeprefix("export ").strip() + if key not in allowed: + extra.append(key) + if extra: + assertion["passed"] = False + assertion["detail"] += " unexpected keys: " + ",".join(extra) + return assertion, None + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--root", required=True) + parser.add_argument("--summary", required=True) + args = parser.parse_args() + + required = ["go", "python3", "git"] + optional = ["docker", "wrangler", "multica", "node", "npm"] + + assertions = [] + skipped = [] + failures = [] + + for name in required: + ok = command_available(name) + assertions.append({"name": f"{name} available", "passed": ok}) + if not ok: + failures.append({"category": "environment_failure", "name": f"{name} missing", "detail": name}) + + for name in optional: + if not command_available(name): + skipped.append({"category": "skipped_missing_capability", "name": f"{name} missing", "detail": name}) + + env_assertion, env_skip = env_file_check() + if env_assertion: + assertions.append(env_assertion) + if not env_assertion["passed"]: + failures.append({"category": "environment_failure", "name": env_assertion["name"], "detail": env_assertion["detail"]}) + if env_skip: + skipped.append(env_skip) + + commands = [run_git_status(args.root)] + if commands[0]["status"] != "ok": + failures.append({"category": "environment_failure", "name": "git status failed", "detail": commands[0]["stderr"]}) + + status = "ok" if not failures else "failed" + summary = { + "schema_version": 1, + "phase": "preflight", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": commands, + "assertions": assertions, + "skipped": skipped, + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status == "ok" else 1) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/run.sh b/harness/scripts/r3-1/run.sh new file mode 100755 index 00000000..d5430149 --- /dev/null +++ b/harness/scripts/r3-1/run.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +export MNEMON_R3_ROOT="${MNEMON_R3_ROOT:-$ROOT}" +export MNEMON_R3_RUN_ID="${MNEMON_R3_RUN_ID:-$(date -u +"%Y%m%dT%H%M%SZ")}" +export MNEMON_R3_OUT_DIR="${MNEMON_R3_OUT_DIR:-$ROOT/.mnemon-dev/tmp/r3-1-test/$MNEMON_R3_RUN_ID}" + +PHASES=( + preflight + mnemonhub-contract + cloudflare-local + cloudflare-live + multica-runtime-gate + multica-writeback + docker-local + docker-cloudflare + live-multica + zh-complex-cases +) + +usage() { + cat <<'USAGE' +Usage: + harness/scripts/r3-1/run.sh --phase + harness/scripts/r3-1/run.sh --smoke + harness/scripts/r3-1/run.sh --full + +Phases: + preflight + mnemonhub-contract + cloudflare-local + cloudflare-live + multica-runtime-gate + multica-writeback + docker-local + docker-cloudflare + live-multica + zh-complex-cases +USAGE +} + +run_phase() { + local phase="$1" + local runner="$SCRIPT_DIR/$phase/run.sh" + if [[ ! -x "$runner" ]]; then + echo "[r3-1] phase runner missing or not executable: $runner" >&2 + return 2 + fi + echo "[r3-1] phase=$phase out=$MNEMON_R3_OUT_DIR" + "$runner" +} + +if [[ $# -eq 0 ]]; then + usage + exit 2 +fi + +case "$1" in + --phase) + [[ $# -eq 2 ]] || { usage >&2; exit 2; } + run_phase "$2" + ;; + --smoke) + run_phase preflight + run_phase mnemonhub-contract + ;; + --full) + for phase in "${PHASES[@]}"; do + run_phase "$phase" + done + ;; + -h|--help) + usage + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/harness/scripts/r3-1/zh-complex-cases/run.py b/harness/scripts/r3-1/zh-complex-cases/run.py new file mode 100644 index 00000000..61f2efd2 --- /dev/null +++ b/harness/scripts/r3-1/zh-complex-cases/run.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import shutil +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + + +REQUIRED_AGENTS = [ + "mnemon-planner", + "mnemon-researcher", + "mnemon-implementer", + "mnemon-reviewer", + "mnemon-integrator", +] + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +class Runner: + def __init__(self, out_dir, profile, workspace_id, multica_mode, multica_source): + self.out_dir = Path(out_dir) + self.profile = profile + self.workspace_id = workspace_id + self.multica_mode = multica_mode + self.multica_source = Path(multica_source) if multica_source else Path("") + self.docker_image = os.environ.get("MNEMON_R3_ZH_MULTICA_DOCKER_IMAGE", "golang:1.24") + self.go_mod_cache = self.out_dir / "go-mod-cache" + self.go_build_cache = self.out_dir / "go-build-cache" + self.commands = [] + self.seq = 0 + + def run(self, label, args, stdin=""): + self.seq += 1 + log = self.out_dir / f"{self.seq:02d}-{label}.log" + proc = subprocess.run(args, input=stdin, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + log.write_text(proc.stdout + proc.stderr, encoding="utf-8") + self.commands.append({ + "name": " ".join(redact_args(args)), + "status": "ok" if proc.returncode == 0 else "failed", + "exit_code": proc.returncode, + "log": str(log), + }) + return proc, log + + def multica(self, label, *args, stdin=""): + base = self.multica_base() + base += ["--profile", self.profile] + if self.workspace_id: + base += ["--workspace-id", self.workspace_id] + return self.run(label, base + list(args), stdin=stdin) + + def multica_base(self): + if self.multica_mode == "host": + return ["multica"] + self.go_mod_cache.mkdir(parents=True, exist_ok=True) + self.go_build_cache.mkdir(parents=True, exist_ok=True) + return [ + "docker", "run", "--rm", "-i", + "-e", "GOTOOLCHAIN=auto", + "-v", f"{self.multica_source}:/multica-server:ro", + "-v", f"{Path.home() / '.multica'}:/root/.multica:ro", + "-v", f"{self.go_mod_cache}:/go/pkg/mod", + "-v", f"{self.go_build_cache}:/root/.cache/go-build", + "-w", "/multica-server", + self.docker_image, + "go", "run", "./cmd/multica", + ] + + def readiness_error(self): + if self.multica_mode == "host": + if not shutil.which("multica"): + return "multica CLI is unavailable" + return "" + if self.multica_mode != "docker": + return f"unsupported multica mode: {self.multica_mode}" + if not shutil.which("docker"): + return "docker CLI is unavailable" + if not self.multica_source.exists(): + return f"Multica source directory is unavailable: {self.multica_source}" + if not (self.multica_source / "go.mod").exists(): + return f"Multica source directory does not contain go.mod: {self.multica_source}" + if not (Path.home() / ".multica").exists(): + return "~/.multica profile directory is unavailable" + return "" + + +def redact_args(args): + out = [] + skip_next = False + for item in args: + if skip_next: + out.append("") + skip_next = False + continue + out.append(item) + if item in {"--token", "--mnemon-control-token"}: + skip_next = True + return out + + +def load_json_text(text, fallback): + try: + return json.loads(text) + except Exception: + return fallback + + +def workspace_from_daemon(profile, out_dir, commands): + proc = subprocess.run(["multica", "--profile", profile, "daemon", "status", "--output", "json"], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + log = Path(out_dir) / "00-daemon-status.log" + log.write_text(proc.stdout + proc.stderr, encoding="utf-8") + commands.append({"name": "multica --profile daemon status --output json", "status": "ok" if proc.returncode == 0 else "failed", "exit_code": proc.returncode, "log": str(log)}) + if proc.returncode != 0: + return "", proc + data = load_json_text(proc.stdout, {}) + workspaces = data.get("workspaces") or [] + if not workspaces: + return "", proc + return (workspaces[0] or {}).get("id", ""), proc + + +def issue_id(proc): + data = load_json_text(proc.stdout, {}) + return data.get("id", "") + + +def first_run_id(text): + data = load_json_text(text, []) + if isinstance(data, dict): + data = data.get("runs") or data.get("items") or [] + if not data: + return "" + return data[0].get("id") or data[0].get("task_id") or "" + + +def create_issue(runner, title, description, assignee="", parent=""): + args = ["issue", "create", "--title", title, "--description", description, "--status", "todo", "--priority", "medium", "--output", "json"] + if assignee: + args += ["--assignee", assignee] + if parent: + args += ["--parent", parent] + proc, _ = runner.multica("issue-create", *args) + if proc.returncode != 0: + return "" + return issue_id(proc) + + +def add_comment(runner, issue, body): + proc, _ = runner.multica("issue-comment", "issue", "comment", "add", issue, "--content-stdin", "--output", "json", stdin=body) + return proc.returncode == 0 + + +def poll_run(runner, issue, attempts=18, delay=5): + last = "" + for _ in range(attempts): + proc, _ = runner.multica("issue-runs", "issue", "runs", issue, "--output", "json") + last = proc.stdout + if proc.returncode == 0: + rid = first_run_id(proc.stdout) + if rid: + return rid + time.sleep(delay) + return first_run_id(last) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--out-dir", required=True) + parser.add_argument("--profile", required=True) + parser.add_argument("--workspace-id", default="") + parser.add_argument("--multica-mode", choices=["docker", "host"], default="docker") + parser.add_argument("--multica-source", default="") + parser.add_argument("--run-id", required=True) + args = parser.parse_args() + + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + commands = [] + workspace_id = args.workspace_id + if not workspace_id: + workspace_id, daemon_proc = workspace_from_daemon(args.profile, out_dir, commands) + if daemon_proc.returncode != 0: + return write_summary(args, commands, [], [], [], skipped="Multica daemon/profile is not ready") + if not workspace_id: + return write_summary(args, commands, [], [], [], skipped="Multica workspace id is unavailable") + + runner = Runner(out_dir, args.profile, workspace_id, args.multica_mode, args.multica_source) + runner.commands.extend(commands) + readiness_error = runner.readiness_error() + if readiness_error: + return write_summary(args, runner.commands, [], [], [], skipped=readiness_error) + agents_proc, _ = runner.multica("agent-list", "agent", "list", "--output", "json") + if agents_proc.returncode != 0: + return write_summary(args, runner.commands, [], [], [], failed="agent list failed") + agents = {item.get("name"): item for item in load_json_text(agents_proc.stdout, [])} + missing_agents = [name for name in REQUIRED_AGENTS if name not in agents] + if missing_agents: + return write_summary(args, runner.commands, [], [], [], failed="missing agents: " + ", ".join(missing_agents)) + + suffix = args.run_id.replace("T", "").replace("Z", "") + session_title = f"中文验收-R3-1并发PoC上下文复用 {suffix}" + session_desc = """背景: +本次验收模拟真实 OA 协作:同一发布窗口同时影响库存、退款风控和会员补偿。 + +共享上下文: +- ctx:发布窗口=最近一次发布同时影响库存同步、退款规则、会员补偿。 +- ctx:风险登记=重复补偿、库存超卖、误拒退款、客服承诺不一致。 +- ctx:证据索引=日志片段、指标快照、手工核对表、回滚预案。 + +验收意图: +触发多个 PoC、共享上下文复用、二轮 follow-up 和 integrator 汇总。""" + session = create_issue(runner, session_title, session_desc) + if not session: + return write_summary(args, runner.commands, [], [], [], failed="session root create failed") + + shared_context_comment = """Mnemon 更新: 共享上下文 + +## 状态 + +display-only + +## 摘要 + +ctx:发布窗口、ctx:风险登记、ctx:证据索引、ctx:运行手册在三个 PoC 中复用。该评论不触发 provider 执行,只作为 OA 可见上下文。 + +## 事件引用 + +event:zh-parallel-poc-overlap/shared-context""" + add_comment(runner, session, shared_context_comment) + + poc_specs = [ + ("PoC-A", "华东仓库存偏差排查", [("mnemon-researcher", "库存同步链路与差异样本核验"), ("mnemon-reviewer", "库存超卖风险与回滚条件复核")]), + ("PoC-B", "退款风控误杀排查", [("mnemon-researcher", "风控规则命中样本与误杀比例核验"), ("mnemon-implementer", "退款白名单临时运行手册")]), + ("PoC-C", "会员权益补偿与客服口径", [("mnemon-implementer", "补偿脚本与重复补偿保护"), ("mnemon-reviewer", "客服承诺边界与发布风险复核")]), + ] + created = [{"kind": "session", "id": session, "title": session_title}] + run_targets = [] + for poc_id, title, children in poc_specs: + root_title = f"中文验收-{poc_id}/{title} {suffix}" + root_desc = f"""任务目标: +围绕 {title} 完成第一轮 PoC 判断。 + +共享上下文: +引用 ctx:发布窗口、ctx:风险登记、ctx:证据索引。 + +要求: +planner 需要拆解证据、识别与其他 PoC 的重叠依赖,并避免重复创建 shared context。""" + root_id = create_issue(runner, root_title, root_desc, "mnemon-planner", session) + if not root_id: + return write_summary(args, runner.commands, created, run_targets, [], failed=f"{poc_id} root create failed") + created.append({"kind": "poc-root", "poc_id": poc_id, "id": root_id, "title": root_title}) + run_targets.append(root_id) + add_comment(runner, root_id, f"Mnemon 更新: {poc_id} 共享上下文引用\n\n## 摘要\n\n复用 ctx:发布窗口 与 ctx:风险登记,禁止复制成新的 canonical context。\n\n## 事件引用\n\nevent:{poc_id}/shared-context-ref") + for agent, child_title in children: + full_title = f"中文验收-{poc_id}/{child_title} {suffix}" + desc = f"""任务目标: +{child_title} + +共享上下文: +- ctx:发布窗口 +- ctx:风险登记 +- ctx:证据索引 + +本角色产出: +请给出中文结构化进展、证据、风险、下一步。不要直接判定最终结论。 + +回写提示: +accepted 后通过 Mnemon display writeback 写 Multica comment/metadata。""" + child_id = create_issue(runner, full_title, desc, agent, root_id) + if not child_id: + return write_summary(args, runner.commands, created, run_targets, [], failed=f"{poc_id} child create failed") + created.append({"kind": "assignment", "poc_id": poc_id, "id": child_id, "title": full_title, "agent": agent}) + run_targets.append(child_id) + + follow_title = f"中文验收-follow-up/跨PoC证据冲突对齐 {suffix}" + follow_desc = """Round 2 follow-up: +请对齐 PoC-A 库存差异、PoC-B 退款误杀、PoC-C 会员补偿之间的共享发布窗口。 + +必须引用: +- ctx:发布窗口 +- ctx:风险登记 +- 三个 PoC 第一轮 issue + +目标: +给出继续观察、局部补偿、灰度回滚和客服统一口径的条件。""" + follow_id = create_issue(runner, follow_title, follow_desc, "mnemon-integrator", session) + if not follow_id: + return write_summary(args, runner.commands, created, run_targets, [], failed="follow-up create failed") + created.append({"kind": "follow-up", "id": follow_id, "title": follow_title, "agent": "mnemon-integrator"}) + run_targets.append(follow_id) + + run_ids = [] + for issue in run_targets: + rid = poll_run(runner, issue, attempts=6, delay=5) + if rid: + run_ids.append({"issue_id": issue, "run_id": rid}) + + return write_summary(args, runner.commands, created, run_targets, run_ids) + + +def write_summary(args, commands, created, run_targets, run_ids, skipped="", failed=""): + logs = "\n".join(Path(cmd["log"]).read_text(encoding="utf-8", errors="replace") for cmd in commands if Path(cmd["log"]).exists()) + docker_mode = args.multica_mode == "docker" + assertions = [ + {"name": "created session root plus three PoC roots", "passed": len([x for x in created if x.get("kind") == "session"]) == 1 and len([x for x in created if x.get("kind") == "poc-root"]) == 3}, + {"name": "created at least six role assignments", "passed": len([x for x in created if x.get("kind") == "assignment"]) >= 6}, + {"name": "created follow-up issue for second round", "passed": any(x.get("kind") == "follow-up" for x in created)}, + {"name": "at least three assigned issues produced runs", "passed": len(run_ids) >= 3}, + {"name": "multiple PoCs share context comments", "passed": "ctx:发布窗口" in logs and "ctx:风险登记" in logs}, + {"name": "Dockerized Multica CLI executed visible case", "passed": (not docker_mode) or any((cmd.get("name") or "").startswith("docker run") for cmd in commands)}, + {"name": "logs do not expose Multica token literal", "passed": "mul_" not in logs}, + ] + failures = [] + skipped_items = [] + if skipped: + skipped_items.append({"category": "skipped_missing_capability", "detail": skipped}) + if failed: + failures.append({"category": "live_failure", "name": "zh complex Multica case", "detail": failed}) + if not skipped and not failed and not all(item["passed"] for item in assertions): + failures.append({"category": "acceptance_failure", "name": "zh complex Multica case assertions", "detail": "one or more Chinese complex case assertions failed"}) + status = "skipped" if skipped_items else ("ok" if not failures else "failed") + summary = { + "schema_version": 1, + "phase": "zh-complex-cases", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": commands, + "assertions": assertions, + "skipped": skipped_items, + "failures": failures, + "metadata": { + "multica_mode": args.multica_mode, + "multica_source": args.multica_source, + }, + "created": created, + "run_targets": run_targets, + "runs": run_ids, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + return 0 if status in {"ok", "skipped"} else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/scripts/r3-1/zh-complex-cases/run.sh b/harness/scripts/r3-1/zh-complex-cases/run.sh new file mode 100755 index 00000000..9a3a6ea2 --- /dev/null +++ b/harness/scripts/r3-1/zh-complex-cases/run.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" + +PHASE="zh-complex-cases" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +python3 "$SCRIPT_DIR/run.py" \ + --summary "$PHASE_DIR/summary.json" \ + --out-dir "$PHASE_DIR" \ + --profile "${MNEMON_MULTICA_PROFILE:-desktop-api.multica.ai}" \ + --workspace-id "${MNEMON_MULTICA_WORKSPACE_ID:-}" \ + --multica-mode "${MNEMON_R3_ZH_MULTICA_MODE:-docker}" \ + --multica-source "${MNEMON_MULTICA_SOURCE_DIR:-/Users/grivn/go/src/github.com/multica-ai/multica/server}" \ + --run-id "${MNEMON_R3_RUN_ID:-manual}" diff --git a/harness/testdata/mnemonhub/sync-abi/auth-invalid.json b/harness/testdata/mnemonhub/sync-abi/auth-invalid.json new file mode 100644 index 00000000..fdc3d6f6 --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/auth-invalid.json @@ -0,0 +1,8 @@ +{ + "name": "auth-invalid", + "kind": "http_auth", + "route": "/sync/push", + "method": "POST", + "token": "wrong-token", + "want": {"status_code": 401} +} diff --git a/harness/testdata/mnemonhub/sync-abi/auth-missing.json b/harness/testdata/mnemonhub/sync-abi/auth-missing.json new file mode 100644 index 00000000..c183b5e0 --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/auth-missing.json @@ -0,0 +1,8 @@ +{ + "name": "auth-missing", + "kind": "http_auth", + "route": "/sync/push", + "method": "POST", + "token": "", + "want": {"status_code": 401} +} diff --git a/harness/testdata/mnemonhub/sync-abi/pull-after-cursor.json b/harness/testdata/mnemonhub/sync-abi/pull-after-cursor.json new file mode 100644 index 00000000..04ea07af --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/pull-after-cursor.json @@ -0,0 +1,20 @@ +{ + "name": "pull-after-cursor", + "kind": "pull", + "principal": "replica-b@team", + "replica_id": "local-b", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "seed_principal": "replica-a@team", + "seed_replica_id": "local-a", + "seed_events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-mem", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "memory event"} + } + ], + "want": {"events": 1, "after_cursor_events": 0} +} diff --git a/harness/testdata/mnemonhub/sync-abi/pull-excludes-origin.json b/harness/testdata/mnemonhub/sync-abi/pull-excludes-origin.json new file mode 100644 index 00000000..02f325a6 --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/pull-excludes-origin.json @@ -0,0 +1,20 @@ +{ + "name": "pull-excludes-origin", + "kind": "pull", + "principal": "replica-a@team", + "replica_id": "local-a", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "seed_principal": "replica-a@team", + "seed_replica_id": "local-a", + "seed_events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-origin", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "origin event"} + } + ], + "want": {"events": 0} +} diff --git a/harness/testdata/mnemonhub/sync-abi/push-conflict.json b/harness/testdata/mnemonhub/sync-abi/push-conflict.json new file mode 100644 index 00000000..fefbcec8 --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/push-conflict.json @@ -0,0 +1,24 @@ +{ + "name": "push-conflict", + "kind": "push_conflict", + "principal": "replica-a@team", + "replica_id": "local-a", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-conflict", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "original body"} + }, + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-conflict", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "same idempotency key, different body"} + } + ], + "want": {"accepted": 0, "rejected": 0, "conflicts": 1} +} diff --git a/harness/testdata/mnemonhub/sync-abi/push-malformed.json b/harness/testdata/mnemonhub/sync-abi/push-malformed.json new file mode 100644 index 00000000..b485a58a --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/push-malformed.json @@ -0,0 +1,19 @@ +{ + "name": "push-malformed", + "kind": "push", + "principal": "replica-a@team", + "replica_id": "local-a", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-malformed", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "bad digest"}, + "corrupt_digest": true + } + ], + "want": {"accepted": 0, "rejected": 1, "conflicts": 0} +} diff --git a/harness/testdata/mnemonhub/sync-abi/push-out-of-scope.json b/harness/testdata/mnemonhub/sync-abi/push-out-of-scope.json new file mode 100644 index 00000000..9accc199 --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/push-out-of-scope.json @@ -0,0 +1,18 @@ +{ + "name": "push-out-of-scope", + "kind": "push", + "principal": "replica-a@team", + "replica_id": "local-a", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-skill", + "resource_ref": {"kind": "skill", "id": "project"}, + "fields": {"name": "project"} + } + ], + "want": {"accepted": 0, "rejected": 1, "conflicts": 0} +} diff --git a/harness/testdata/mnemonhub/sync-abi/push-replay.json b/harness/testdata/mnemonhub/sync-abi/push-replay.json new file mode 100644 index 00000000..f5de2d5b --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/push-replay.json @@ -0,0 +1,18 @@ +{ + "name": "push-replay", + "kind": "push_replay", + "principal": "replica-a@team", + "replica_id": "local-a", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-replay", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "replayed memory"} + } + ], + "want": {"accepted": 1, "rejected": 0, "conflicts": 0, "stored_events": 1} +} diff --git a/harness/testdata/mnemonhub/sync-abi/push-valid.json b/harness/testdata/mnemonhub/sync-abi/push-valid.json new file mode 100644 index 00000000..8200c4ac --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/push-valid.json @@ -0,0 +1,18 @@ +{ + "name": "push-valid", + "kind": "push", + "principal": "replica-a@team", + "replica_id": "local-a", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-valid", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "hub accepted memory"} + } + ], + "want": {"accepted": 1, "rejected": 0, "conflicts": 0} +} diff --git a/harness/testdata/mnemonhub/sync-abi/status-ok.json b/harness/testdata/mnemonhub/sync-abi/status-ok.json new file mode 100644 index 00000000..02882e3d --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/status-ok.json @@ -0,0 +1,20 @@ +{ + "name": "status-ok", + "kind": "status", + "principal": "replica-a@team", + "replica_id": "local-a", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "seed_principal": "replica-a@team", + "seed_replica_id": "local-a", + "seed_events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-status", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "counted"} + } + ], + "want": {"hub_events_received": 1} +}